diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5a19094..4f11b93 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.5", + "version": "0.4.6", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.6.0", + "version": "1.6.1", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.1.5", + "version": "1.1.6", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.3.5", + "version": "1.3.6", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.3.6", + "version": "1.3.7", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.1.1", + "version": "1.1.2", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.1.6", + "version": "1.1.7", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 5a19094..4f11b93 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.5", + "version": "0.4.6", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.6.0", + "version": "1.6.1", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.1.5", + "version": "1.1.6", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.3.5", + "version": "1.3.6", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.3.6", + "version": "1.3.7", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.1.1", + "version": "1.1.2", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.1.6", + "version": "1.1.7", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/AGENTS.md b/AGENTS.md index ae855dc..7826d36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins//.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline.** 26 of 39 descriptions and 9 of 39 bodies exceed their FAIL tier, and the `Kyberforge.CompositionNote` Vale rule fires 10 errors across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow`. Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99. `skill-size-check` will not warn you about the Vale half, so check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Retrofitted plugin by plugin under #99 (see `docs/spec/gates.md`). Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and no `references/` file is linted by anything, so prose relocated out of a body to satisfy the word gate lands outside the prose gate. It has two independent causes and closing either alone changes nothing — `docs/spec/gates.md` has both, issue #117 tracks it. Check both gates: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. diff --git a/CONTEXT.md b/CONTEXT.md index fa9dfbf..5e9754d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,8 +16,12 @@ decisions. **Preload tax**: The always-on context cost of every installed skill's `name` and `description`, charged from the -first token of every session whether the skill is invoked or not. Measurement method and current -figure: ADR-0020. +first token of every session whether the skill is invoked or not. Measurement method: ADR-0020. Its +**23,427 characters is the pre-retrofit baseline, not a current reading** — measured at the decision +commit, before #99. Across the same 39 skills it is **10,478 characters** (~2,620 tokens) as of +2026-09-01. Both figures move with the corpus, so re-derive rather than quote either: sum +`len(name) + len(description)` over the frontmatter of every `plugins/*/.apm/skills/*/SKILL.md`, +folding block scalars as `scripts/skill-size-check.sh` does. _Avoid_: context cost, token overhead **Skill context contract**: @@ -26,6 +30,14 @@ trigger clause, at most one capability clause, and a boundary clause, and nothin and the target-resolution walk: `docs/spec/gates.md`. _Avoid_: skill budget, size limit +**Routing target**: +The skill or agent name a boundary clause sends work to. It **resolves** when a skill or agent of +that name is reachable from the file being checked, and **dangles** when none is — a route the router +cannot take. Dangling is a blocking ERROR in route notation (`/name`, `→ name`) and a SUGGESTION for +a bare name nothing else in the sentence corroborates. Verdicts and the resolution walk: +`docs/spec/gates.md`. +_Avoid_: route, pointer, cross-reference + **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 @@ -35,7 +47,9 @@ _Avoid_: router body, thin body **Hand-invoked skill**: A skill reached only by typing its slash command, declared `disable-model-invocation: true`. The host withholds it from the model-visible listing entirely, so it pays no preload tax and its description -becomes human-facing text. Exemplar: `zoom-out`. +becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other skill can route to +a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops working the moment `x` +takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`. _Avoid_: manual skill, disabled skill **Delegation discipline**: diff --git a/apm.lock.yaml b/apm.lock.yaml index b236fe6..ca28793 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,12 +1,12 @@ lockfile_version: '1' -generated_at: '2026-08-17T06:44:18.931217+00:00' +generated_at: '2026-08-30T11:38:46.419301+00:00' apm_version: 0.28.0 dependencies: - repo_url: Defame1297/holocron name: bin host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.1.3 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.1.5 virtual_path: plugins/bin is_virtual: true package_type: marketplace_plugin @@ -81,13 +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:7acfaa806ae8379fe6351441ce8f973ac3ff8a02deb4a5e145968203d1176ef8 + content_hash: sha256:d7cca4972651b241a5450679289fdb6f0e3a37aef9dff0498cb4b3340b048781 declared_license: MIT exec_status: gated_pending_approval - repo_url: Defame1297/holocron name: core host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.1.1 virtual_path: plugins/core is_virtual: true @@ -134,8 +134,8 @@ dependencies: - repo_url: Defame1297/holocron name: git host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.3.3 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.3.5 virtual_path: plugins/git is_virtual: true package_type: marketplace_plugin @@ -236,13 +236,13 @@ dependencies: .claude/skills/pc-run/references/README.md: sha256:ad42b9013dbd44b8c4515c15317c5e9e6b235bc656ad5a03e037eb4bc6f8782c .claude/skills/pc-run/references/failure-patterns.md: sha256:59d913c001483c3770d269fdf5e83cf1d7578e0ae879928a1404bf93bb395994 .claude/skills/pc-run/references/sources.md: sha256:7ca8b7106e7ee5c2c7d1fbd3dc9e0d2a89c18eba79145cbab55bc8e39eef9087 - content_hash: sha256:6d0c8fcec32d8fb8321b539cc567c4241342cfa33e5d0b6f66697d2f8b784fdd + content_hash: sha256:8c47bd35572c331f1a1b61a8be159d9690a1bf286958a85826fde01939e3007b declared_license: MIT - repo_url: Defame1297/holocron name: gitea host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.3.4 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.3.6 virtual_path: plugins/gitea is_virtual: true package_type: marketplace_plugin @@ -313,10 +313,10 @@ dependencies: .claude/skills/gitea-labels-milestones/references/labels.md: sha256:be8ff5ce4dbfb31dd2bdcc425a769dca94887c5c8acd47c78e1e21ccfc24dcba .claude/skills/gitea-labels-milestones/references/milestones.md: sha256:ff1f1b0c8ecb6967c940d1ca8bebf2a2c7995414936f8ac7f5ff23d1dc01ac3f .claude/skills/gitea-labels-milestones/references/sources.md: sha256:a1a1d3381dfb0dc8f331be0b3ab49f28e94b992fb4809ca09be91ce33047dd1e - .claude/skills/gitea-prs/README.md: sha256:0346388f642641c56cef82ba1ee576a187d048eee66fab839a17417c81122cbc + .claude/skills/gitea-prs/README.md: sha256:31b6ad46379feee8ac7d837030f93ec04be20b7d11021b295dc6d27b4340a05b .claude/skills/gitea-prs/SKILL.md: sha256:bf70832f3d97064c9816d680390da84c256a971d7804317e1814b4ee6a622b4d .claude/skills/gitea-prs/references/merging.md: sha256:5f8b55b1a729f122ee265fb5da69369fc27434a13dd02fc112801f945d2c9d19 - .claude/skills/gitea-prs/references/pull-requests.md: sha256:631874a7cddfcf678a2beffa5578fa7848bcef36be1251fa13fb0ccc543bcaf7 + .claude/skills/gitea-prs/references/pull-requests.md: sha256:d6cefd0cf270f8d844536311e67bfa45cd07f024aa0a649de1938af306ae7109 .claude/skills/gitea-prs/references/reviews.md: sha256:1089cf45a75a562bbb29de6538848c4f4bd627d5a7e36bf9b2ab6496f45f962b .claude/skills/gitea-prs/references/sources.md: sha256:ac48a3018443b56acb5c3d55d6c04e62103f5bac71b8185e2bd085fb7e165431 .claude/skills/gitea-releases/README.md: sha256:102625066c34c7b5a16e25b7506a87f876e9b123b334f5ffba73ab1523a57ed7 @@ -327,12 +327,12 @@ dependencies: .claude/skills/gitea-workflow/README.md: sha256:338f79d0b3c25741848fce841a5094ef27cbc7e4a6228404557b2d9594df68fa .claude/skills/gitea-workflow/SKILL.md: sha256:a8266ceb335c56eb68cc09b3f81690c52c4dada9f25b48c3a46ac46b0ad3b761 .claude/skills/gitea-workflow/references/sources.md: sha256:171d3a5a36cb9d637302516501a1e9fc7a4bd058968dc9244412c335aa2de68b - content_hash: sha256:8149467df78678963dbb140aa254f606a53491f3cb7e5d97a1211be426e7892e + content_hash: sha256:16d5b4462c3f4a506c1c74172d4d67bf0dacc4218676de4e8dfe551b2f31a3f8 declared_license: MIT - repo_url: Defame1297/holocron name: kyberforge host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.6.0 virtual_path: plugins/kyberforge is_virtual: true @@ -530,7 +530,7 @@ dependencies: - repo_url: Defame1297/holocron name: lint host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.1.6 virtual_path: plugins/lint is_virtual: true @@ -1873,7 +1873,7 @@ deployments: owners: - git.dev.rkdr.net/Defame1297/holocron/plugins/gitea active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/gitea - content_hash: sha256:0346388f642641c56cef82ba1ee576a187d048eee66fab839a17417c81122cbc + content_hash: sha256:31b6ad46379feee8ac7d837030f93ec04be20b7d11021b295dc6d27b4340a05b - kind: project-relative target: claude value: .claude/skills/gitea-prs/SKILL.md @@ -1900,7 +1900,7 @@ deployments: owners: - git.dev.rkdr.net/Defame1297/holocron/plugins/gitea active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/gitea - content_hash: sha256:631874a7cddfcf678a2beffa5578fa7848bcef36be1251fa13fb0ccc543bcaf7 + content_hash: sha256:d6cefd0cf270f8d844536311e67bfa45cd07f024aa0a649de1938af306ae7109 - kind: project-relative target: claude value: .claude/skills/gitea-prs/references/reviews.md diff --git a/apm.yml b/apm.yml index 7ae2ec4..4584124 100644 --- a/apm.yml +++ b/apm.yml @@ -1,5 +1,5 @@ name: holocron -version: 0.4.5 +version: 0.4.6 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.6.0: + kyberforge#1.6.1: 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.5 + version: 0.4.6 owner: name: Defame1297 email: defame1297@rkdr.net @@ -79,31 +79,31 @@ marketplace: - name: kyberforge description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. source: ./plugins/kyberforge - version: 1.6.0 + version: 1.6.1 category: Developer Tools - name: bin description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. source: ./plugins/bin - version: 1.1.5 + version: 1.1.6 category: Utilities - name: git description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it. source: ./plugins/git - version: 1.3.5 + version: 1.3.6 category: Version Control - name: gitea description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone. source: ./plugins/gitea - version: 1.3.6 + version: 1.3.7 category: Version Control - name: core description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. source: ./plugins/core - version: 1.1.1 + version: 1.1.2 category: Productivity - name: mattpocock-skills @@ -115,5 +115,5 @@ marketplace: - name: lint description: Skills and agents for configuring and running linters. source: ./plugins/lint - version: 1.1.6 + version: 1.1.7 category: Developer Tools diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md index 19853b4..09b7da2 100644 --- a/docs/adr/0020-skill-description-and-body-context-contract.md +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -127,8 +127,15 @@ clause**, and a **boundary clause**. Capability enumeration, output-format detai 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. + with no `.claude/` or `.agents/` anywhere, produced findings identical to the working tree. The + figures that reproduction recorded — 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 + missing references, 58 SUGGESTIONs — are the **pre-retrofit** corpus as it stood when the + experiment ran, kept here as the evidence for the install-independence claim, not as a current + reading. *Amended 2026-09-01: the #99 retrofit took the first three to zero. Measured at that + date over the same install-free tree: 0 description FAILs, 0 body FAILs, 0 dangling targets, 0 + missing references, 29 SUGGESTIONs.* What the experiment establishes is that the two trees agree, + not what either measured; re-derive rather than quote — + `bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md`. - **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 @@ -185,8 +192,11 @@ becomes the system prompt of a fresh context. The rationale for the 900-word FAI 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 +running it directly over `plugins/*/.apm/agents/*.agent.md` exits 1 with 900-word body FAILs on +`git-orchestrate` and `gitea-orchestrate`. *Amended 2026-09-01: this sentence named a third agent, +`apm-orchestrate`, at 1,080 words. It is 876 today — a SUGGESTION, not a FAIL. Counts are +deliberately no longer pinned here: agent bodies are edited like any other file and a figure in this +paragraph goes stale the moment one is trimmed. Run the command.* 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. @@ -203,8 +213,14 @@ 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`. +at `plugins/bin/skills/zoom-out/SKILL.md:4`, and `zoom-out` was — at the time of that check, when it +was the only carrier — the one installed skill absent from the model-visible skill listing in a live +session. It remains invocable as `/zoom-out`. `caveman` has since taken the flag as well, so the +corpus now has **two** carriers. Do not read a carrier list off this page; re-derive it: + +``` +grep -l '^disable-model-invocation: true' plugins/*/.apm/skills/*/SKILL.md +``` ### Merging siblings @@ -219,10 +235,13 @@ rather than the core job. 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 +after normalising `skill`/`agent`, where before they were closer. It has kept deepening since: the +#99 retrofit added `finding-criteria.md` to `skill-audit`, drawing it level with `agent-audit`. Both +figures move with the next retrofit, so measure rather than quote — +`ls plugins/kyberforge/.apm/skills//references/ | grep -c '\.md$'`. 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 @@ -233,11 +252,13 @@ which tier each rule is in, because the failure this ADR is most exposed to is a | 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` | +| 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 | +| boundary target resolves to a real skill or agent — **three** verdicts, not two (ERROR when written in route notation — `/name`, or any arrow form; or when a *terminal* bare name's own sentence names another target that resolves. SUGGESTION otherwise. INFO, "DID NOT RUN", exit 0, when no skill universe could be determined for the path at all — no authoring root above it, no apm package root, no declared apm dependencies, no deployed `.claude/` or `.agents/` tree: the targets are named and left unchecked) | skills, agents | deterministic | same | +| boundary clause absent — `absent` (SUGGESTION) † | skills, agents | deterministic | same | +| an arrow clause is present but no target can be read out of it — `unparsed` (SUGGESTION) † | skills, agents | deterministic | same | +| one arrow clause naming two or more targets, of which only the first is resolved (SUGGESTION, issue #107) † | 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 | @@ -254,6 +275,21 @@ that guessed at them would be a worse gate than no gate, because it would be bel 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. +**† These four, and only these four, are lifted for a hand-invoked file** — one whose frontmatter +carries `disable-model-invocation: true`, read as a boolean by `hand_invoked()` in all three scripts. +No validator knew the field existed (issue **#108**), so every routing SUGGESTION above fired on +exactly the shape the *Invocation as a design axis* section mandates, and the boundary-clause +remedy — "so the router knows where NOT to send this skill" — was addressed to a router that cannot +see the skill at all. An author who took the advice made the file worse. + +What does **not** lift is the point of the carve-out. Both body word tiers stand: the body is still +loaded on invocation and still competes with the caller's live conversation. The 400-character +description FAIL stands: that description is not preloaded, but it is the one line a user reads when +choosing from the `/` menu, and the ceiling is an outlier stop rather than a routing-quality budget — +which is exactly why the 250-character *target* is the tier that lifts. And a target the description +does happen to name is still resolved and can still dangle as a blocking ERROR. Mechanics, and the +reason the field is read as a boolean rather than as a mention of the key: `docs/spec/gates.md`. + 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`, @@ -264,7 +300,8 @@ raise an error: one followed by an ordinary lowercase noun is a compound **modif 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 +arrow; a `/slash` target reached through a route verb is **not** exempt and takes the same test. +*Amended 2026-08-31 — the `/slash` half is reversed: it is exempt too. See the amendment below.* 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 @@ -296,6 +333,46 @@ Three pre-existing contradictions are fixed in the same change, because they are - `description-quality.md:45-50` has no FAIL condition for internal-mechanics content, which is why `skill-author/SKILL.md:102` never bit. +## Amendment (2026-08-31): route notation short-circuits the follower test, `/name` included + +The Enforcement section above exempts the arrow form from the follower test and then withholds the +same exemption from `/name`: "a `/slash` target reached through a route verb is **not** exempt and +takes the same test." That half is reversed. **Both spellings of route notation are exempt, and the +exemption is decided before the follower test rather than weighed against it.** + +Three things make the original call wrong rather than merely strict. + +**It contradicted the promise the same paragraph makes.** Route notation is offered to an author as +the way to get a target checked unconditionally — the SUGGESTION text on an unpromoted target says +so in as many words: "write it as `/name` or `-> name` and it will be checked properly." Under the +original rule that was true of one of the two spellings. `-> name` reached `_add()` with +`strict=True` from both its call sites; `/name` did not, so it fell through to `_terminal()` and any +follower outside `FOLLOWER_OK` demoted it. `Do not use for Y — use /no-such-skill afterwards.` exited +0 — and, before the companion visibility fix, in total silence. + +**The follower test's own justification does not reach `/name`.** That test exists for *prose*: a +bare hyphenated token followed by an ordinary lowercase noun is a compound modifier, "pre-commit +hooks" and "pull-request template". A leading slash is Claude Code's invocation syntax and occurs in +no English compound, so there is no attributive reading to protect. The exemption was withheld from +the one shape the rule it protects against cannot describe. + +**`FOLLOWER_OK` is a closed whitelist of roughly eighty words, and a closed list is the wrong thing +to hang a blocking gate on.** Leaving `/name` under it made *whether a commit is blocked* depend on +whether someone had thought to enumerate the next word — the gate failing open on its own +unfamiliarity. The bare-target path keeps the follower test precisely because it needs a brake it can +justify; the notation path asked for one and was given the same brake by accident. + +What is unchanged: the **corroboration** branch. A *bare* terminal name still earns its blocking +ERROR only from a resolving sibling in the same sentence, and a compound modifier still cannot +dangle at all. The conservative tuning that decision rests on is untouched — this amendment moves one +explicitly-marked spelling out from under it, not the prose path. + +Verified on fixtures inside a synthetic plugin tree: `… Do not use for Y — use /no-such-skill +afterwards.` exits 1, while the same sentence with the bare `no-such-skill` exits 0 at SUGGESTION, +and rises to a blocking ERROR the moment a resolving sibling joins it. The reasoning is recorded at +the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two +mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape. + ## Consequences **Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39 @@ -311,10 +388,17 @@ carries is the ordinary one for hot gates: a gate expensive enough to be inconve 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. +`level: error` like every other rule in that style, so at decision time `pre-commit run --all-files` +was 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 would have fixed those and still been blocked. The two gates +wanted fixing together, and were. *Amended 2026-09-01: that figure is historical. The Vale prefilter +over the same 39 files now reports 0 errors, 0 warnings and 0 suggestions, so +`Kyberforge.CompositionNote` fires nowhere in the corpus today. The rule is still hot and still +independent of `skill-size-check`, so a new description can reintroduce it; `skill-size-check` does +not cover the Vale half, and no `references/` file is linted by anything (`docs/spec/gates.md` has +both causes, issue #117 tracks them). Re-derive rather than quote —* +`bash plugins/kyberforge/.apm/skills/skill-audit/scripts/vale-wrap.sh plugins/*/.apm/skills/*/SKILL.md`. **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 @@ -342,8 +426,8 @@ and `git-*` families — where every sibling shares a keyword and boundary claus — 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. +**Four broken routing targets were found; two were fixed here and two shortly after.** 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 @@ -353,14 +437,24 @@ routing degrades, the tier is the first thing to revisit. 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.** +- `research` routes to `neuledge-context`, which exists only inside that string. Was **live**; + **fixed under #99** — the retrofitted description names no such target. - `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`. + stray space introduced by YAML wrapping mid-token, breaking the skill name in preloaded text. Was + **live**, reported as a dangling `gitea-labels`; **fixed under #99** — the name now folds intact. -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. +So the check fired on 3 of the 4 against the base commit and on 2 at the tip of the change that +carried this ADR. **The corpus dangling set is now empty**, and that is asserted rather than +observed: `tests/test-adr0020-targets.sh` pins the set as empty, so a new boundary clause naming a +non-existent skill fails the suite instead of joining a backlog. `tests/test-skill-size-check.sh` +probed the three original names rather than asserting a count; as each was retrofitted its probe was +**removed, not skipped**, because a `pass "SKIP: …"` branch is an assertion-free result counted in +the totals and makes the suite look one test stronger than it is. That file's commentary survives the +probes and states the rule. Re-derive the current set — never read it off this page: + +``` +bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md | grep 'does not resolve' +``` **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 diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 8fa6a99..cdd1e3c 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -98,11 +98,57 @@ loudly (`Error: jq is required but not installed`). ## Skill and agent context gates (ADR-0020) The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`, -runs `scripts/skill-size-check.sh`. That scope means it never lints the -`plugins/kyberforge/docs/research/examples/` reference skills. It is also shipped to external repos -as `kyberforge-skill-size-check` (see +runs `scripts/skill-size-check.sh`. It is also shipped to external repos as +`kyberforge-skill-size-check` (see [External consumers](#external-consumers-the-root-pre-commit-hooksyaml)). +**Two things fall outside that scope, both deliberately.** The `[^/]+/SKILL\.md$` tail admits only a +`SKILL.md` sitting directly in a skill directory under `.apm/skills/`: + +- the `plugins/kyberforge/docs/research/examples/` reference skills, which are vendored upstream + corpus and not this repo's to gate; +- `plugins/kyberforge/.apm/skills/skill-author/assets/templates/SKILL.md` — inside `.apm/skills/`, + but two directories deeper. It is the `FILL IN:` scaffold `skill-author` copies, so its + `description: >` is a comment block rather than a description and every ADR-0020 measurement over + it would be meaningless. A reader adjusting the pattern needs to know it is there. + +Everything else it matches exactly, with nothing over- or under-caught. Re-derive both halves: + +``` +git ls-files | grep -cE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the real skills +git ls-files | grep -E '^plugins/[^/]+/\.apm/skills/.*SKILL\.md$' \ + | grep -vE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the scaffold only +``` + +The first count equals the number of skill directories (`ls -d plugins/*/.apm/skills/*/ | wc -l`); +the second returns exactly the template. The remaining unmatched `SKILL.md` files in the tree are the +generated flat mirror, which is excluded by the `.apm/` segment on purpose — a mirror edit is drift, +not an authoring change. + +### `skill-frontmatter`, the other hook on that scope + +A second `repo: local` pre-commit hook, `skill-frontmatter`, runs on the **same** `files:` pattern at +the same stage. It is a short shell loop: for each file, `grep -q "^name:"` and +`grep -q "^description:"`, failing with "missing required frontmatter fields" if either is absent. + +**It overlaps ADR-0020's "description present and non-empty" FAIL, and the overlap is not clean.** +The ADR (`:95-101`) requires that question be decided on the **YAML-folded value** and nowhere else, +precisely because a line regex gets it wrong in both directions. Measured on fixtures: + +| Frontmatter | `skill-frontmatter` | `skill-size-check` | +|---|---|---| +| `description:` with no value, then `model: sonnet` | passes — the key is on a line | ERROR, "missing or empty" | +| `"description": …` (quoted key, valid YAML) | **fails** — `^description:` does not match | passes, description read normally | + +So the grep is not a second opinion on presence. It is blind to the shape ADR-0020 was written +against, and it is the only one of the two that objects to a quoted key. Neither disagreement is +currently live in the corpus, and the honest reading is that presence is `skill-size-check`'s +question — the grep's contribution to it is noise on one shape and silence on the other. + +What the grep does add is the `name:` key, which **no** ADR-0020 check reads: a `SKILL.md` with no +`name:` passes `skill-size-check` at exit 0. That is its real and only unique coverage, and the +reason not to fold it into the size gate on the grounds of redundancy. + ### Two independent gate families, neither replaced the other **Family 1 — agentskills.io spec backstop** (unchanged, conformance not quality): @@ -141,7 +187,7 @@ A boundary-clause target that resolves to no skill or agent has **three** possib | Verdict | When | |---|---| | **SUGGESTION** — the default | the target does not resolve and neither promotion condition below holds | -| **blocking ERROR** | the target is **terminal** (not a compound modifier) **and** either written in route notation (`/name` for any name; `-> name` only when the name is hyphenated — see the gap below) **or** corroborated by another target in the same sentence that *does* resolve | +| **blocking ERROR** | the target is written in **route notation** — `/name` for any name, or any arrow form (a bare `-> name` only when the name is hyphenated, a backticked `` -> `name` `` for any — see the gap below); **or** it is a bare **terminal** name (not a compound modifier) **corroborated** by another target in the same sentence that *does* resolve | | **INFO, "DID NOT RUN"** | no skill universe could be determined for the path at all — the targets are named and left unchecked, exit 0 | The default is deliberately soft because a hyphenated word in a boundary clause is as likely to be a @@ -149,14 +195,36 @@ tool, a file format or an English compound as a route: "pre-commit hooks" is pro never reaches the check at all, being a compound modifier rather than a terminal name. The SUGGESTION text says how to opt in — write it as `/name` or `-> name` and it gets checked properly. -**Known gap: the arrow form only works for hyphenated names.** Target extraction is built on -`NAME_HYPH` (`scripts/skill-size-check.sh:543`), which requires at least one hyphen, and -`ARROW_BOUNDARY` (`:561`) inherits that. So `-> gitea-prs` is extracted and checked, while -`-> triage` is not extracted at all — no ERROR, no SUGGESTION, exit 0. The unicode arrow `→` is not -recognised in either case. This makes the SUGGESTION's own advice unsafe for a single-word skill: -taking it silences the finding rather than checking it. `/name` has no such restriction and is the -form to prefer. Tracked as a defect; `tests/test-adr0020-targets.sh` has one arrow case and its -target happens to be hyphenated, so nothing currently covers this. +**The two promotion conditions are not symmetric, and the order matters.** `_add()` decides +**notation first**: when the name is written `/name`, or reached through any arrow form, the target +is marked error-eligible there and the terminal test is never run. Terminality gates only the *bare* +path — a name in prose earns its error from corroboration, and a compound modifier can never dangle. +Reading the row as "terminal AND (notation OR corroborated)" gets the notation half backwards: it +predicts that `` … Do not use for Y — use /no-such-skill afterwards. `` is a SUGGESTION, because +`afterwards` is a follower outside `FOLLOWER_OK`. It exits 1. That was the defect — `-> name` reached +`_add()` with `strict=True` from both its call sites and `/name` did not, so the one spelling +ADR-0020 offers an author who wants a route checked unconditionally was the one spelling a stray +follower could silence. + +**Known gap: a BARE arrow target must be hyphenated.** Target extraction is built on `NAME_HYPH` in +`scripts/skill-size-check.sh`, which requires at least one hyphen, and `ARROW_BOUNDARY` inherits +that. So `Not X -> gitea-prs` is extracted and checked, while `Not X -> triage` yields no target. +The exclusion is deliberate, not an oversight: `research`, `triage`, `forge`, `prototype` and `tdd` +are all real skill names *and* ordinary English, so a bare single-word rule would flag most of the +corpus. The marked spellings carry no such restriction — `` `triage` `` and `/triage` are both +extracted — and are the forms to prefer. **Both arrow spellings are recognised:** `ARROW_MARKED`, +`ARROW_BOUNDARY` and `BOUNDARY_ARROW` are each built from `(?:->|→)`, so the unicode arrow `→` +behaves exactly like `->` in every case below. Cite these constants by symbol name, never by line +number: the script moves often enough that a pinned line lands a reader in an unrelated comment +block and reads as plausible. + +**The gap is no longer silent.** It used to be exactly that — no ERROR, no SUGGESTION, exit 0 — which +made the dangling-target SUGGESTION's own advice unsafe for a single-word skill: taking it silenced +the finding instead of checking it. `boundary_clause_status()` now separates the case out and +reports it as `unparsed` (see below), naming the parse failure and the two spellings that fix it. +The target is still not *resolved*; the author is now told so rather than left with a green gate. +`tests/test-adr0020-targets.sh` covers both directions (`arrow-single-word-target` and the silent +control `arrow-single-word-marked`). Corroboration is what makes the soft default safe: a sentence whose *other* target resolves is demonstrably a routing sentence, so a sibling that does not resolve is a typo rather than a noun, and @@ -200,17 +268,109 @@ through `.claude/skills/` alone, so **the same commit measured 2 dangling target machine and 6 on a fresh clone**. A gate shipping hot with no baseline cannot give two answers. Verified fixed: running the hook over a tree holding only `plugins/` and the root `apm.yml`, with no -`.claude/` or `.agents/` anywhere, produces findings identical to the working tree — **26 description -FAILs, 9 body FAILs, 2 dangling targets, 0 missing references, 58 SUGGESTIONs**. +`.claude/` or `.agents/` anywhere, produced findings identical to the working tree. The figures that +reproduction recorded — 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 missing references +— are the pre-retrofit corpus as it stood when the experiment was run, kept here as the evidence for +the install-independence claim. They are not current: the retrofit under #99 took the first three to +zero. What the experiment establishes is that the two trees agree, not what either measured. + +### Boundary-clause detection: three outcomes, not two + +`boundary_clause_status()` returns one of three values, and the two findings get separate messages: + +| Status | When | Reported as | +|---|---|---| +| `present` | a prose marker (`do not`, `instead`, `rather than`, `not for`) or an arrow clause was found | nothing | +| `absent` | neither was found | SUGGESTION: add a boundary clause, in either form | +| `unparsed` | an arrow clause was found and **no target could be read out of it** | SUGGESTION: the clause is present — this is a *parse* failure, not a missing clause | + +The third had to be split out. Collapsing it into `absent` is a **wrong** finding, not a strict one: +it sends the author to add a clause that is already there. Three of them instead reworded a correct +clause until the regex accepted it, one stripping the very filename that discriminates the skill +from its neighbour (**#110**). + +`unparsed` is narrow and certain on purpose. It fires only on the arrow form, which *always* names a +target, so zero targets means the name is written in a shape the extractor cannot see — in practice +a bare single-word target, per the known gap above, and the message says to write it `` `name` `` or +`/name`. A **prose** clause yielding no target is not reported at all: "Do not use for anything else" +is a complete and legitimate boundary clause that names nowhere to go. + +**One arrow, one target.** An arrow clause naming two or more targets draws its own SUGGESTION, +quoting both names and asking for a split, because only the first is ever resolved: the conjunction +continuation (`CONT_MARKED` / `CONT_ANY`) is wired to the prose route verbs and never to arrows. So +`Not X -> a or b` resolved `a`, left `b` resolved by nothing and reported by nothing, and then let +the audit print "1 of 1 boundary target(s) resolve" on a clause naming two — a gate under-reporting +its own coverage, which is the one failure mode ADR-0020 says a gate must not have (**#107**). The +clause is **rejected rather than the arrow scan extended**: extending it would widen the resolver's +deliberately conservative false-positive tuning across every arrow in the corpus, where splitting +costs the author one full stop. The convention is one arrow per target — `Not X -> a. Not Y -> b.` — +already what every retrofitted `gitea-*` skill does in practice, now stated in +`skill-author`'s `references/contract.md` instead of being folklore. + +**Dotted filenames in a boundary clause now parse.** `CLAUSE_BODY` — what may sit between `Not` and +the arrow — used to be `[^.;]`, a class that cannot cross a `.`, so every clause naming a dotted +filename between the two (`AGENTS.md`, `.vale.ini`, `.pre-commit-config.yaml`) was invisible to both +`BOUNDARY_ARROW` and `ARROW_BOUNDARY`. The two resulting failures were different sizes (**#110**): + +- with a **backticked** target the clause was *misdiagnosed*. The backtick sweep still extracted the + target, so the route was checked, but the gate reported "no boundary clause" on a clause that was + present and working. That is the misdiagnosis the three rewordings above came from. +- with a **bare** target the clause was *unchecked*. `ARROW_BOUNDARY` is the only extractor for a + bare arrow target, so `Not AGENTS.md -> no-such-skill` produced no target, no dangling report and + no missing-clause SUGGESTION. Silence, not noise — the worse of the two. + +`CLAUSE_BODY` is now `(?:[^.;]|\.(?=\S))`: a dot inside a filename is followed by a non-space, a +sentence-ending dot by whitespace or end of string, so the class crosses `AGENTS.md` and still stops +at a real sentence end. **Read the second bullet forward as well as back:** a bare target sitting +after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the +same clause used to pass unchecked in silence. ### SUGGESTION-only checks -Three more, deterministic to measure but judgment to act on: +Deterministic to measure, judgment to act on: -- a description with **no boundary clause at all**; +- a description with **no boundary clause at all** (`absent`); +- an **arrow clause whose target could not be read** (`unparsed`); +- an **arrow clause naming more than one target**; - a `## Gotchas` section with **more than five entries**; - a `## Gotchas` section over **25% of the body**. +### Hand-invoked skills are exempt from the routing rules, and only those + +A skill or agent whose frontmatter carries `disable-model-invocation: true` skips three checks: + +- the boundary-clause check, `absent` and `unparsed` alike; +- the multi-target arrow check; +- the 250-character description **target** (`hand_invoked()` in `scripts/skill-size-check.sh`). + +It keeps the 400-character description FAIL and **both** body word tiers, and if its description +does happen to name a target, that target is still resolved and can still dangle. + +Why the exemption is right: `disable-model-invocation: true` removes the skill from the +model-visible listing entirely — it is not preloaded, and the Skill tool refuses to call it — so its +description is never matched against user intent. ADR-0020 and `skill-author`'s contract therefore +give such a skill **one plain human-facing sentence**: no trigger list, no boundary clause. No +validator knew the field existed (**#108**), so the boundary-clause SUGGESTION fired on exactly the +shape the contract mandates, and its remedy — "so the router knows where NOT to send this skill" — +was addressed to a router that cannot see the skill at all. An author who followed the advice made +the file worse. There is no router to inform. + +The half that does **not** lift is the point. The body is still loaded on invocation and still +competes with the caller's live conversation, so neither body tier moves. The 400-character ceiling +stands too: a hand-invoked description is not preloaded, but it is still the one line the user reads +when choosing from the `/` menu, and that ceiling is an outlier stop rather than a routing-quality +budget — which is precisely why the 250-character target is the tier that lifts. + +The field is read as a **boolean**, not as a mention of the key. PyYAML already resolves the +unquoted YAML 1.1 booleans, so the extra handling catches a quoted `"true"`, which a host reads as +truthy; `disable-model-invocation: false` is the model-invoked case written out longhand and buys +nothing. A frontmatter parse failure returns false rather than raising — the flag is a *modifier* on +other checks, and `description_value()` on the same text already reports the broken frontmatter, so +raising here would diagnose one file twice two different ways. + +`caveman` and `zoom-out` are the two carriers here. `tests/test-skill-size-check.sh` pins both +halves — what the carve-out lifts, each with a flag-removed control, and what it must not. + ### `verbose: true` is load-bearing The hook is declared `verbose: true` so the SUGGESTION tier is audible. pre-commit prints nothing at @@ -281,8 +441,10 @@ script.** `scripts/skill-size-check.sh` applies its body gate to whatever path i 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` +exits 1 today with 900-word body FAILs on `git-orchestrate` and `gitea-orchestrate`. (Counts are +deliberately not pinned here — agent bodies are edited like any other file, and a figure in this +paragraph goes stale the moment one is trimmed. Run the command.) 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; doing so silently enforces a gate ADR-0020 declines to set. @@ -292,20 +454,39 @@ knows the difference; doing so silently enforces a gate ADR-0020 declines to set **The ADR-0020 gates ship hot, with no baseline file.** A shrinking baseline recording each non-compliant skill's current numbers was considered and rejected in favour of hot gates. -Two independent hot gates are currently red, and the first will not warn you about the second. +**The corpus is now clean on both gates.** Issue **#99** retrofitted all 39 skills plugin by plugin; +`kyberforge` was the last wave, after which the corpus was swept as a whole rather than per plugin. +Each sweep is followed by an **independent review round**: a fresh agent with no memory of the +retrofit re-measures the corpus and files what it finds, and the round repeats until one lands no +findings. The rounds are recorded as comments on **#99** — read the current state off that thread, +which is why no round count is pinned here. | Gate | Current findings | |---|---| -| `skill-size-check` | **26 of 39** descriptions and **9 of 39** bodies exceed their FAIL tier; 2 dangling targets; 58 SUGGESTIONs | -| `Kyberforge.CompositionNote` (Vale) | **10 errors across four skills**: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-workflow` | +| `skill-size-check` | **0 of 39** descriptions and **0 of 39** bodies exceed their FAIL tier; 0 dangling targets; SUGGESTIONs outstanding (count not pinned — see below) | +| `Kyberforge.CompositionNote` (Vale) | **0 errors** — the four `gitea-*` carriers were all retrofitted | + +**The SUGGESTION count is deliberately not recorded here.** It moves with every skill edit *and* +with every change to the gate's own tiering, so any figure written down is stale by the next commit. +Measure it instead: + +``` +bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md | grep -c '^SUGGESTION' +pre-commit run skill-size-check --all-files # same findings, via the hook +``` + +A non-zero count is the expected steady state, not a regression. SUGGESTIONs exit 0 and block +nothing; only the two FAIL tiers, the dangling-target ERROR and the missing-`references/` ERROR do. +Read the count as a work queue, and the FAIL columns above as the gate. `Kyberforge.CompositionNote` is the ADR-0020 Vale rule banning composition and architecture prose -from a description. Every Vale rule here is `level: error` with no ignorable tier, so touching any of -those four skills means fixing its prose findings as well as its size findings. +from a description. Every Vale rule here is `level: error` with no ignorable tier, so a description +that reintroduces one blocks the commit even though no skill carries one today. -Consequence: editing a non-compliant skill *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; it guarantees convergence and avoids a half-state. Tracked as Gitea issue **#99**. +Because nothing is grandfathered, the gates now bite on **first commit**: a new skill, or an edit +that pushes a description past 400 characters or a body past 900 words, is blocked until it +complies. That is the steady state the retrofit was for — it is no longer true that an unrelated +one-line fix to a skill requires retrofitting that skill first. Check where a skill stands before starting, and check **both** gates: @@ -434,6 +615,35 @@ passing one explicit file per invocation. The two manifests scope **differently Narrowing a `.vale.ini` glob to a `plugins/`-shaped path to "tighten" it breaks the consumer case, and `check-vale-style-sync`'s probe set is built to catch exactly that. +### The blind spot: `references/` is unlinted, for two independent reasons + +Every `references/*.md` file in the corpus is outside the prose gate. Count them with +`git ls-files | grep -cE '^plugins/[^/]+/\.apm/skills/[^/]+/references/.*\.md$'` rather than reading +a figure here; it moves with every retrofit. This is the gap that matters most, because the context +contract's own remedy for an over-long body is to move prose **into** `references/` — the gate pushes +text across its own boundary and then stops watching it. + +**Closing either cause alone changes nothing.** There are two, and they are independent: + +| Cause | Where | Effect on a `references/` file | +|---|---|---| +| the `Kyberforge` style is scoped `[**/SKILL.md]` | `skill-audit/assets/vale/.vale.ini` | matches no section, so Vale lints 0 files and exits 0 | +| the hook's `files:` regex is `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` | `vale-audit-prefilter-skill` in `.pre-commit-config.yaml` | the file is never handed to Vale at all | + +Verified both ways. Handing skill-audit's `vale-wrap.sh` a reference file directly — bypassing +pre-commit entirely, so only the style scope is in play — prints `0 errors … in 0 files` and exits 0, +where the same wrapper on a `SKILL.md` reports `in 1 file`. And the hook's `files:` regex, applied to +`git ls-files`, selects only the skill-directory `SKILL.md` files scoped at the top of this page, so +pre-commit never hands Vale a reference file to begin with. Widening the glob to `[**/*.md]` would +still lint nothing through the hook; widening the hook's `files:` alone would hand Vale files its own +config declines to match, which is the [0-file NOT RUN](#a-0-file-vale-run-is-not-run) shape — a +green run that measured nothing. **Issue #117** records the style-scope half; the hook half has to +land in the same change or the fix is cosmetic. + +The consumer manifest is a third axis and does not rescue this either: `.pre-commit-hooks.yaml`'s +`(^|/)SKILL\.md$` is layout-agnostic but still filename-shaped, so an external repo running +`kyberforge-vale-audit-skill` has the same gap. + ### `vale-wrap.sh`, never bare `vale` Both audit skills' Step 1 and both pre-commit hooks call **each copy's own** diff --git a/plugins/bin/.apm/skills/caveman/README.md b/plugins/bin/.apm/skills/caveman/README.md new file mode 100644 index 0000000..63ba67c --- /dev/null +++ b/plugins/bin/.apm/skills/caveman/README.md @@ -0,0 +1,29 @@ +# caveman + +Ultra-compressed output mode: drop articles, filler and pleasantries, keep the technical substance exact. + +## What it does + +Switches the agent into a terse register — no articles, no hedging, no pleasantries, fragments allowed, arrows for causality — while leaving technical terms, code blocks and quoted error strings untouched. The mode is *sticky*: once turned on it stays on for every subsequent response until the user says "stop caveman" or "normal mode", rather than decaying back to normal prose after a few turns. + +It carries one built-in escape hatch. Security warnings, confirmations for irreversible actions, multi-step sequences where fragment order could be misread, and any request to clarify are answered in normal prose, then the compressed register resumes. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`. This is the single most important thing to know about this skill: **the model cannot route to it.** No other skill can hand off to it, and no phrasing in a user's request will cause it to be selected automatically. The only way in is the human typing `/caveman`. + +That is deliberate — output style is the user's choice, not an inference the router should make on their behalf. It is also why the description reads as one plain human-facing sentence rather than carrying the trigger phrasing and boundary clause a routable skill needs. + +## Usage + +```text +/caveman +``` + +Then keep working normally. To leave the mode, say "stop caveman" or "normal mode". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — persistence rule, compression rules, worked examples, and the auto-clarity exception | diff --git a/plugins/bin/.apm/skills/caveman/SKILL.md b/plugins/bin/.apm/skills/caveman/SKILL.md index 85770a3..dea5b4a 100644 --- a/plugins/bin/.apm/skills/caveman/SKILL.md +++ b/plugins/bin/.apm/skills/caveman/SKILL.md @@ -1,17 +1,16 @@ --- name: caveman +disable-model-invocation: true description: > - Ultra-compressed communication mode. Cuts token usage ~75% by dropping - filler, articles, and pleasantries while keeping full technical accuracy. - Use when user says "caveman mode", "talk like caveman", "use caveman", - "less tokens", "be brief", or invokes /caveman. + Ultra-compressed output mode that drops articles, filler and pleasantries while + keeping technical substance exact, cutting token usage by roughly 75%. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. ## Persistence -ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". +ACTIVE EVERY RESPONSE once user type `/caveman`. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". ## Rules diff --git a/plugins/bin/.apm/skills/diagnose/README.md b/plugins/bin/.apm/skills/diagnose/README.md new file mode 100644 index 0000000..6e2c414 --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/README.md @@ -0,0 +1,35 @@ +# diagnose + +A six-phase discipline for hard bugs and performance regressions: feedback loop → reproduce → hypothesise → instrument → fix with a regression test → clean up. + +## What it does + +Imposes an order of operations on debugging so the agent cannot skip to guessing. The load-bearing phase is the first one: build a fast, deterministic, agent-runnable pass/fail signal for the bug. Everything downstream — bisection, hypothesis testing, instrumentation — just consumes that signal, so the skill refuses to advance to Phase 2 without one, and says so explicitly rather than hypothesising blind. + +The remaining phases each carry a constraint worth knowing about: hypotheses are generated 3–5 at a time and must be falsifiable, so the first plausible idea cannot anchor the whole investigation; every debug log is tagged with a unique prefix (`[DEBUG-a4f2]`) so cleanup is a single grep; the regression test is written before the fix and only at a seam that exercises the real bug pattern; and the run closes by asking what would have prevented the bug, handing off to `improve-codebase-architecture` when the answer is architectural. + +Performance regressions take a branch of their own inside Phase 4 — baseline measurement and bisection, not logs. + +## Conditional reading + +Neither reference file is read on every run; `SKILL.md` names the condition for each. + +- `references/feedback-loops.md` is read when Phase 1 has no signal yet, or when the loop you have is slow or intermittent. +- `references/regression-seams.md` is read when Phase 5 leaves you unsure whether the available seam is deep enough — or whether one exists at all. + +## Usage + +```text +/diagnose +``` + +Describe the bug or the regression. For filing and triaging a reported bug rather than diagnosing it, use `triage`; for test-first feature work, use `tdd`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The six phases and their gates — what must be true before each one ends | +| `references/feedback-loops.md` | Loaded when Phase 1 has no loop or the loop is too weak: ten ways to construct one ordered by cost, how to sharpen an existing loop, handling intermittent bugs, and what to ask the user for when the bug resists reproduction | +| `references/regression-seams.md` | Loaded when Phase 5 is unsure about the seam: what makes a seam correct, the four shapes of a too-shallow seam, and what to do when no correct seam exists | +| `assets/hitl-loop.template.sh` | Copy-and-edit bash template for the last-resort human-in-the-loop feedback loop, cited by `references/feedback-loops.md`. Provides `step` and `capture` helpers and prints captured values as `KEY=VALUE` for the agent to parse | diff --git a/plugins/bin/.apm/skills/diagnose/SKILL.md b/plugins/bin/.apm/skills/diagnose/SKILL.md index ed55bda..1e60479 100644 --- a/plugins/bin/.apm/skills/diagnose/SKILL.md +++ b/plugins/bin/.apm/skills/diagnose/SKILL.md @@ -1,6 +1,9 @@ --- name: diagnose -description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +description: > + Use when the user says "diagnose this" or "debug this", reports something + broken, throwing, or failing, or says something got slow. Not filing or + triaging a reported bug -> `triage`. Not test-first feature work -> `tdd`. --- # Diagnose @@ -15,40 +18,11 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -### Ways to construct one — try them in roughly this order +**If you do not yet have such a signal, read `references/feedback-loops.md`** — ten ways to build one ordered by cost, and what to ask the user for when the bug resists reproduction entirely. -1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. -2. **Curl / HTTP script** against a running dev server. -3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. -4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. -5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. -6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. -7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. -8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. -9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +**If you do have one, it is probably not sharp enough yet.** Make it faster and more deterministic, and make it assert on the exact symptom rather than "didn't crash" — a 30-second flaky loop is barely better than no loop. If it stays slow or intermittent after that, read that file's "Iterate on the loop itself" and "Intermittent bugs" sections. -Build the right feedback loop, and the bug is 90% fixed. - -### Iterate on the loop itself - -Treat the loop as a product. Once you have _a_ loop, ask: - -- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) -- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) -- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) - -A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. - -### Non-deterministic bugs - -The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. - -### When you genuinely cannot build a loop - -Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. - -Do not proceed to Phase 2 until you have a loop you believe in. +Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. ## Phase 2 — Reproduce @@ -57,7 +31,7 @@ Run the loop. Watch the bug appear. Confirm: - [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. -- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] The failure is reproducible across multiple runs. If it is intermittent, `references/feedback-loops.md` defines the rate high enough to debug against — go back to Phase 1 and raise it. - [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. Do not proceed until you reproduce the bug. @@ -90,19 +64,17 @@ Tool preference: ## Phase 5 — Fix + regression test -Write the regression test **before the fix** — but only if there is a **correct seam** for it. +Write the regression test **before the fix** — but only at a **correct seam**: one where the test exercises the real bug pattern as it occurs at the call site. If the available seam looks too shallow, or you cannot tell whether it is, read `references/regression-seams.md`. -A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. +**If no correct seam exists, that itself is the finding.** Note it and carry it into Phase 6 — the architecture is preventing the bug from being locked down. -**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. +At a correct seam: -If a correct seam exists: - -1. Turn the minimised repro into a failing test at that seam. +1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. 3. Apply the fix. 4. Watch it pass. -5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. +5. Re-run the Phase 1 feedback loop against the original, un-narrowed scenario. ## Phase 6 — Cleanup + post-mortem diff --git a/plugins/bin/.apm/skills/diagnose/scripts/hitl-loop.template.sh b/plugins/bin/.apm/skills/diagnose/assets/hitl-loop.template.sh similarity index 100% rename from plugins/bin/.apm/skills/diagnose/scripts/hitl-loop.template.sh rename to plugins/bin/.apm/skills/diagnose/assets/hitl-loop.template.sh diff --git a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md new file mode 100644 index 0000000..8804543 --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md @@ -0,0 +1,40 @@ +# Constructing and sharpening a feedback loop + +A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for the bug. Build the right one and the bug is 90% fixed. This file covers the whole arc: building a loop, sharpening one you already have, and escalating when the bug resists reproduction. + +## Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `assets/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +## Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +## Intermittent bugs — raise the reproduction rate + +If the loop only sometimes fails, the goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +## When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: + +- access to whatever environment reproduces it, +- a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or +- permission to add temporary production instrumentation. + +Do **not** proceed to hypothesise without a loop. A hypothesis you cannot falsify against a signal is a guess, and the fix that follows it is unverifiable. diff --git a/plugins/bin/.apm/skills/diagnose/references/regression-seams.md b/plugins/bin/.apm/skills/diagnose/references/regression-seams.md new file mode 100644 index 0000000..26176a8 --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/references/regression-seams.md @@ -0,0 +1,24 @@ +# Judging a regression-test seam + +Read this when Phase 5 leaves you unsure whether the seam available for the regression test is the correct one — either because the obvious seam looks shallow, or because there appears to be no seam at all. + +## What makes a seam correct + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site: the same entry point, the same participants, the same ordering, and the same state the real caller holds when it goes wrong. + +## Seams that are too shallow + +- A single-caller test when the bug only appears with multiple callers. +- A unit test that cannot replicate the chain of calls that triggered the bug. +- A test that reproduces the symptom by construction — asserting on a value the test itself set — rather than by driving the code path that produces it. +- A test that mocks out the collaborator the bug actually lives in. + +A regression test at a shallow seam gives false confidence. It passes forever, including after a change reintroduces the bug at the real call site, and it will be read by the next maintainer as proof the bug is locked down. + +## When there is no correct seam + +Do not force one, and do not settle for a shallow seam to have something green. Instead: + +1. Apply the fix and verify it against the Phase 1 loop directly. +2. Write down which seams you considered and why each was too shallow. +3. Carry that into Phase 6's "what would have prevented this bug" question. A missing seam is an architecture finding — tangled callers, hidden coupling, or a module with no testable boundary — and the handoff is the `improve-codebase-architecture` skill, with those specifics attached. diff --git a/plugins/bin/.apm/skills/grill-me/README.md b/plugins/bin/.apm/skills/grill-me/README.md new file mode 100644 index 0000000..6197081 --- /dev/null +++ b/plugins/bin/.apm/skills/grill-me/README.md @@ -0,0 +1,27 @@ +# grill-me + +Interview the user relentlessly about a plan or design until the decision tree is fully resolved. + +## What it does + +Turns the agent into an interviewer rather than an implementer. It walks the design tree branch by branch, resolving dependencies between decisions one at a time, and offers its own recommended answer alongside each question so the user has something concrete to push against. Two rules give it its shape: **one question at a time**, and **never ask what the codebase can answer** — if a question is settleable by reading the code, the agent goes and reads the code instead of spending the user's attention on it. + +## Composition + +This is the plain grilling loop, with no documentation side effects. The sibling `grill-with-docs` skill runs the same interview but additionally challenges answers against the project's `CONTEXT.md` glossary and existing ADRs, and writes decisions back into those files as they crystallise. Reach for that one when the project has a domain model worth defending; reach for this one when it does not, or when nothing should be written down yet. + +`triage` composes the documented variant, not this one, when an issue needs fleshing out. + +## Usage + +```text +/grill-me +``` + +Describe the plan or design to be stress-tested. Expect questions one at a time, each with a recommended answer. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — the interview instruction, the one-question-at-a-time rule, and the explore-instead-of-asking rule | diff --git a/plugins/bin/.apm/skills/grill-me/SKILL.md b/plugins/bin/.apm/skills/grill-me/SKILL.md index bd04394..1cc8ede 100644 --- a/plugins/bin/.apm/skills/grill-me/SKILL.md +++ b/plugins/bin/.apm/skills/grill-me/SKILL.md @@ -1,6 +1,10 @@ --- name: grill-me -description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +description: > + Use when the user says "grill me" or wants a plan or design stress-tested by + relentless interview — one question at a time, down each branch of the + decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs -> + `grill-with-docs`. --- Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. diff --git a/plugins/bin/.apm/skills/grill-with-docs/README.md b/plugins/bin/.apm/skills/grill-with-docs/README.md new file mode 100644 index 0000000..1ea62ce --- /dev/null +++ b/plugins/bin/.apm/skills/grill-with-docs/README.md @@ -0,0 +1,37 @@ +# grill-with-docs + +The grilling interview, run against the project's domain model — and writing decisions back into `CONTEXT.md` and ADRs as they land. + +## What it does + +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it five ways: + +- **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. +- **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Stress-tests domain relationships with concrete scenarios**, inventing edge cases that force the user to be precise about where one concept ends and the next begins. +- **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. +- **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. + +Files are created lazily — only when there is something real to write. + +ADRs are offered *sparingly*, and only when all three tests pass: the decision is hard to reverse, it would surprise a future reader without the context, and it was a genuine trade-off with real alternatives. Missing any one of the three means no ADR. + +## Composition + +`grill-me` is the same interview without the documentation side effects — use it when there is no domain model to defend or nothing should be written down yet. `triage` composes this skill (not `grill-me`) at step 4 when an issue needs fleshing out. `improve-codebase-architecture` runs its own grilling loop and borrows this skill's `CONTEXT.md` and ADR discipline for the decisions that come out of it. + +## Usage + +```text +/grill-with-docs +``` + +Describe the plan or design. Expect questions one at a time, each with a recommended answer, and expect `CONTEXT.md` to be edited during the session rather than after it. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the five during-session behaviours, and the three-part ADR test | +| `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | +| `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/.apm/skills/grill-with-docs/SKILL.md b/plugins/bin/.apm/skills/grill-with-docs/SKILL.md index 6dad6ad..8610263 100644 --- a/plugins/bin/.apm/skills/grill-with-docs/SKILL.md +++ b/plugins/bin/.apm/skills/grill-with-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: grill-with-docs -description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +description: > + Use when a plan should be stress-tested against the project's domain model — + the interview challenges terms against `CONTEXT.md` and writes decisions into + it and into ADRs as they land. Not a plain interview -> `grill-me`. --- diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/README.md b/plugins/bin/.apm/skills/improve-codebase-architecture/README.md new file mode 100644 index 0000000..509cad7 --- /dev/null +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/README.md @@ -0,0 +1,36 @@ +# improve-codebase-architecture + +Surface architectural friction and propose deepening opportunities — refactors that turn shallow modules into deep ones. + +## What it does + +Looks for places where a codebase is hard to understand, hard to test, or hard for an agent to navigate, and proposes refactors that concentrate behaviour behind smaller interfaces. It runs in three stages: + +1. **Explore.** Reads the domain glossary and any ADRs in the area first, then walks the codebase with an `Explore` sub-agent — organically, noting friction rather than applying fixed heuristics. The **deletion test** is the filter: imagine deleting the module; if complexity vanishes it was a pass-through, if complexity reappears across N callers it was earning its keep. +2. **Present candidates.** A numbered list, each with files, problem, solution and benefits — benefits stated in terms of *locality* and *leverage* and of how tests would improve. No interfaces are proposed yet; the user picks one. +3. **Grilling loop.** Walks the design tree for the chosen candidate, with documentation side effects landing inline as decisions crystallise. + +The skill is opinionated about vocabulary, and that is the point: **module, interface, implementation, depth, seam, adapter, leverage, locality**, used exactly, with no drift into "component", "service", "API" or "boundary". Domain nouns come from `CONTEXT.md`, architecture nouns from `LANGUAGE.md` — so a proposal reads as "the Order intake module", never "the FooBarHandler". + +ADRs are treated as decisions not to be re-litigated. A candidate that contradicts one is surfaced only when the friction is real enough to warrant reopening it, and is marked as such. + +## Composition + +`diagnose` hands off here when a bug's post-mortem concludes that no correct test seam exists, or that callers are tangled — the recommendation is made after the fix is in, not before. The grilling loop follows `grill-with-docs`'s discipline for `CONTEXT.md` entries and ADR offers, and `SKILL.md` names that skill's format documents directly. + +## Usage + +```text +/improve-codebase-architecture +``` + +Point at a codebase or an area of one. Expect a numbered candidate list and a "which of these would you like to explore?" before any interface design happens. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Condensed glossary, key principles, and the three-stage process | +| `LANGUAGE.md` | Skill-root document, cited throughout `SKILL.md`: full definitions of every term, the words each one replaces, and the full principle list | +| `INTERFACE-DESIGN.md` | Skill-root document, read at stage 3 when the user wants alternative interfaces explored: the parallel sub-agent "Design It Twice" pattern, framing the problem space, and the per-agent design constraints | +| `DEEPENING.md` | Skill-root document, cited from `INTERFACE-DESIGN.md`: how to deepen a cluster of shallow modules safely, the four dependency categories (in-process, local-substitutable, remote-but-owned, true external), seam discipline, and the replace-don't-layer testing strategy | diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md index 05984a6..750876a 100644 --- a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md @@ -1,6 +1,11 @@ --- name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +description: > + Use when the user wants to improve architecture, find refactoring + opportunities, consolidate tightly-coupled modules, or make a codebase more + testable and AI-navigable — deepening opportunities that turn shallow modules + into deep ones, informed by `CONTEXT.md` and `docs/adr/`. Not debugging a + failure -> `diagnose`. --- # Improve Codebase Architecture @@ -9,7 +14,7 @@ Surface architectural friction and propose **deepening opportunities** — refac ## Glossary -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." - **Module** — anything with an interface and an implementation (function, class, package, slice). - **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. @@ -20,12 +25,14 @@ Use these terms exactly in every suggestion. Consistent language is the point - **Leverage** — what callers get from depth. - **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): +Key principles: - **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. - **The interface is the test surface.** - **One adapter = hypothetical seam. Two adapters = real seam.** +If a term or principle above is ambiguous in the case in front of you, or you need the definitions and the principles the two lists leave out, read `LANGUAGE.md`. + This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. ## Process @@ -53,7 +60,7 @@ Present a numbered list of deepening opportunities. For each candidate: - **Solution** — plain English description of what would change - **Benefits** — explained in terms of locality and leverage, and also in how tests would improve -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." +**Use CONTEXT.md vocabulary for the domain, and the architecture glossary above for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." **ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. @@ -65,7 +72,7 @@ Once the user picks a candidate, drop into a grilling conversation. Walk the des Side effects happen inline as decisions crystallize: -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. +- **Want to explore alternative interfaces for the deepened module?** Read `INTERFACE-DESIGN.md`. diff --git a/plugins/bin/.apm/skills/prototype/README.md b/plugins/bin/.apm/skills/prototype/README.md new file mode 100644 index 0000000..425a6e8 --- /dev/null +++ b/plugins/bin/.apm/skills/prototype/README.md @@ -0,0 +1,32 @@ +# prototype + +Build a throwaway prototype that answers one design question — either a runnable terminal app or several UI variations. + +## What it does + +Treats a prototype as **throwaway code that answers a question**, and lets the question decide the artifact. `SKILL.md` opens with a two-row dispatch table and the run resolves exactly one row before doing anything else: + +- *"Does this logic / state model feel right?"* → a tiny interactive terminal app that pushes the state machine through the cases that are hard to reason about on paper. +- *"What should this look like?"* → several radically different UI variations on one route, switchable from a floating bottom bar via a URL search param. + +The two branches produce fundamentally different artifacts, so picking wrong wastes the whole prototype. When the question is genuinely ambiguous and the user is unreachable, the skill defaults on the shape of the surrounding code (backend module → logic, page or component → UI) and states the assumption at the top of the prototype rather than silently choosing. + +Six rules apply to both branches: throwaway and visibly named as such, one command to run, no persistence by default, no polish, surface the full state after every action or variant switch, and delete or absorb the prototype when it is done. The *answer* is the only durable output — the skill captures it in a commit message, ADR, issue or `NOTES.md` before the code is deleted. + +## Usage + +```text +/prototype +``` + +State the design question. For production code, use `tdd`; for talking a design through without building anything, use `grill-me`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The branch dispatch table and the rules that apply to both branches | +| `references/logic.md` | The logic branch, read only when that row is selected: when it is the right shape, and how to build the interactive terminal app | +| `references/ui.md` | The UI branch, read only when that row is selected: when it is the right shape, and how to build and switch between the variations | + +Each reference is self-contained — a run reads one of the two, never both. diff --git a/plugins/bin/.apm/skills/prototype/SKILL.md b/plugins/bin/.apm/skills/prototype/SKILL.md index 84478e0..0c9d8e3 100644 --- a/plugins/bin/.apm/skills/prototype/SKILL.md +++ b/plugins/bin/.apm/skills/prototype/SKILL.md @@ -1,6 +1,10 @@ --- name: prototype -description: Build a throwaway prototype to flush out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs". +description: > + Use when the user wants a throwaway prototype to answer a design question about + a data model, state machine or business logic, or to mock up a UI in several + variations. Not production code -> `tdd`. Not talking a design through -> + `grill-me`. --- # Prototype @@ -9,10 +13,12 @@ A prototype is **throwaway code that answers a question**. The question decides ## Pick a branch -Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: +| Question being answered | Build | Reference | +|---|---|---| +| "Does this logic / state model feel right?" | A tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper | `references/logic.md` | +| "What should this look like?" | Several radically different UI variations on one route, switchable via a URL search param and a floating bottom bar | `references/ui.md` | -- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. -- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. +Resolve the row from the user's prompt, the surrounding code, or by asking if the user is around, then read only that reference — each is self-contained. The two branches produce fundamentally different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. diff --git a/plugins/bin/.apm/skills/prototype/LOGIC.md b/plugins/bin/.apm/skills/prototype/references/logic.md similarity index 99% rename from plugins/bin/.apm/skills/prototype/LOGIC.md rename to plugins/bin/.apm/skills/prototype/references/logic.md index 526ecb1..2376fd1 100644 --- a/plugins/bin/.apm/skills/prototype/LOGIC.md +++ b/plugins/bin/.apm/skills/prototype/references/logic.md @@ -9,7 +9,7 @@ A tiny interactive terminal app that lets the user drive a state model by hand. - "I want to feel out what the API should look like before writing it." - Anything where the user wants to **press buttons and watch state change**. -If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). +If the question is "what should this look like" — wrong branch. Read `references/ui.md`. ## Process diff --git a/plugins/bin/.apm/skills/prototype/UI.md b/plugins/bin/.apm/skills/prototype/references/ui.md similarity index 99% rename from plugins/bin/.apm/skills/prototype/UI.md rename to plugins/bin/.apm/skills/prototype/references/ui.md index f3b6e64..9afb121 100644 --- a/plugins/bin/.apm/skills/prototype/UI.md +++ b/plugins/bin/.apm/skills/prototype/references/ui.md @@ -2,7 +2,7 @@ Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. -If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). +If the question is about logic/state rather than what something looks like — wrong branch. Read `references/logic.md`. ## When this is the right shape diff --git a/plugins/bin/.apm/skills/research/META.md b/plugins/bin/.apm/skills/research/META.md deleted file mode 100644 index 824f4c3..0000000 --- a/plugins/bin/.apm/skills/research/META.md +++ /dev/null @@ -1,15 +0,0 @@ -```yaml -version: "1.1" -updated: 2026-06-21 - -when: >- - Invoked when the user wants to gather structured reference documentation for a - tool, library, or API from MCP documentation indexes or web sources. Typically - run before writing a new skill that wraps an external tool, or any time - reference files are needed for a topic. Triggered explicitly - ("/research ") or implicitly when the user asks to look up, - gather, or pull docs for a topic before implementing something. - -references: - - .agents/skills/context7-mcp/SKILL.md # context7-mcp — MCP source channel integrated at step 2 -``` diff --git a/plugins/bin/.apm/skills/research/README.md b/plugins/bin/.apm/skills/research/README.md new file mode 100644 index 0000000..976cd2f --- /dev/null +++ b/plugins/bin/.apm/skills/research/README.md @@ -0,0 +1,31 @@ +# research + +Research a tool, library or API from canonical documentation into a directory of structured per-topic reference files. + +## What it does + +Runs a six-step pipeline: scope against the working directory (what version is actually in use, what is already documented), resolve the topic through Context7, websearch for canonical docs covering whatever Context7 missed, read those sources, deepen one level into the links worth following, then write one markdown file per topic area plus a `sources.md` provenance record. + +Four gotchas at the top of `SKILL.md` shape the whole run, and each exists because of a specific failure: the output path is never inferred (a guessed destination scatters a directory's worth of files through someone's source tree); nothing is written outside that path; no empty topic file is ever written (a stub `troubleshooting.md` reads downstream as researched and closed); and a Context7 "no results", redirect or header-only response does not count as coverage. If no topic area has content, the run writes nothing at all — `sources.md` included — and reports what it searched. + +The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably it grants no subagent tool, so every `WebFetch` is serial and each fetched page lands in the run's own context — which is why steps 4 and 5 insist on reducing each page to notes before fetching the next, and cap deepening at roughly ten extra pages. + +## Composition + +Both reference files are read on condition, never on every run — `SKILL.md` inlines the minimum each step needs (the seven default topic areas at step 1, the four `sources.md` field names and the topic-file frontmatter keys at step 6) and sends the run to the reference only for what it does not carry. Those four field names are matched literally by the downstream provenance validator, so prose written in their place parses as nothing and the check passes having verified nothing — which is why they are inlined rather than deferred. + +## Usage + +```text +/research +``` + +Name the topic and the output path — the skill will stop and ask if the path is missing. Supplying starting URLs is treated as a deliberate source choice and skips Context7 resolution and discovery. For documentation derived from existing code or specs, use `write-docs`; for a bug or incident, use `diagnose`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The four gotchas and the six research steps | +| `references/topics.md` | Read at Step 1 only when what belongs in a default topic is unclear or a custom topic is needed: the per-topic coverage table and the custom-topic naming rule | +| `references/file-format.md` | Read at Step 6 only when the inlined field names do not settle the case: slug derivation, the Context7 slug and URL convention, and what belongs in a topic body | diff --git a/plugins/bin/.apm/skills/research/SKILL.md b/plugins/bin/.apm/skills/research/SKILL.md index 1453ddb..19fffee 100644 --- a/plugins/bin/.apm/skills/research/SKILL.md +++ b/plugins/bin/.apm/skills/research/SKILL.md @@ -1,97 +1,78 @@ --- name: research description: >- - Use when the user wants to research a topic and generate structured reference - markdown files. Handles: finding canonical docs for a tool/library/API via - Context7 MCP or web sources, reading and deepening into linked pages, - organizing extracted content into topic files (overview, installation, - configuration, cli-reference, api-reference, examples, troubleshooting). Do - NOT use when the user wants to write documentation from existing code or specs - (use write-docs), install or manage the neuledge-context MCP server (use - neuledge-context), or research a bug/incident (use diagnose). + Use when the user wants a tool, library, or API researched from canonical + documentation into structured per-topic reference markdown files. Not + documentation written from existing code or specs -> `write-docs`. Not a bug + or incident -> `diagnose`. metadata: category: research allowed-tools: - - WebSearch - - WebFetch + - Grep + - Glob - Read - Write + - WebSearch + - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs model: sonnet --- - +## Gotchas -## Required inputs +- Never infer the output path. A run writes a directory's worth of files, and a guessed destination scatters them through someone's source tree. If the user named no path, stop and ask. +- Write nothing outside the given output path. A file placed beside the agreed directory is one the user never asked for and will not think to look for. +- Never write an empty topic file. A stub `troubleshooting.md` reads downstream as researched and closed. +- A Context7 response that is a "no results" message, a redirect notice, or header-only boilerplate is not coverage. A topic area counts as covered only when the response carries at least one substantive paragraph. -- **Topic** — the subject to research (tool, library, API, concept); inferred from user description if clear, ask if ambiguous -- **Output path** — directory where reference files will be written; must be provided explicitly — do not infer or default -- **Starting URLs** — optional; if provided, skip discovery websearch and read these first +## Step 1 — Scope against the working directory -## Constraints +Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -- Never write files outside the explicitly provided output path -- Skip any default topic file if no relevant content is found for it — do not create empty files -- Create additional topic files beyond the default list when content warrants it (e.g. `webhooks.md`, `rate-limits.md`) -- Subagents handle parallel source reading and link deepening — the orchestrator writes all files; subagents return summaries only, never write directly -- Context7 MCP calls (`resolve-library-id`, `query-docs`) are made only by the orchestrator at step 2 — subagents must not call them -- `sources.md` is always written, even if only one source was read -- Each topic file must have frontmatter with `topic` and `source_keys`; body is prose only — no inline URLs -- Source keys in `sources.md` must be kebab-case slugs: derived from the source domain or page title for web sources; for Context7 sources use `context7-` (e.g. `context7-vercel-next-js`) -- Default topic list and file format spec live in `references/` sub-files — read them at step 1 +The default topic areas are `overview`, `installation`, `configuration`, `cli-reference`, +`api-reference`, `examples` and `troubleshooting` — one file each, and only where content exists. +If what belongs in one of them is unclear, or the topic needs a file outside that set, read +`references/topics.md` for the per-topic coverage table and the custom-topic naming rule. - +## Step 2 — Resolve against Context7 - +If the topic is a library, framework, or API and the user gave no starting URLs, call `resolve-library-id` with the topic name and the user's full question — match quality depends on the question, not the bare name — then `query-docs` once per default topic area. Record each response as a source with slug `context7-`, and mark which topic areas it covered — those skip the web reads at step 4. -## Process +If the library does not resolve, or the user gave starting URLs, go to step 3. Explicit URLs are a source choice; do not second-guess them with a resolution attempt. -1. **Scan codebase.** Search the working directory for existing usage of the topic — imports, config files, version pins, existing reference files. Use findings to narrow research scope (e.g. target the version already in use, skip topics already documented). Read `references/topics.md` for the default topic list and `references/file-format.md` for the output file format spec. +## Step 3 — Discover sources -2. **Try Context7.** If the topic is a library, framework, or API and no starting URLs were provided, call `resolve-library-id` with the topic name and the user's question. If a match resolves, call `query-docs` once per default topic area (see `references/topics.md`). Treat each response as a source summary with slug `context7-` (e.g. `context7-vercel-next-js`). A topic area has sufficient content when the Context7 response contains at least one substantive paragraph — not a "no results" message, redirect notice, or header-only boilerplate. Mark covered topic areas — skip their subagent web reads in step 4. If the library does not resolve, or starting URLs were provided (explicit source choice by the user), skip this step entirely. +If the user gave starting URLs, skip discovery: those URLs are the source list and go straight to step 4. -3. **Discover sources.** For topics not covered by Context7 (or when no starting URLs were provided and Context7 did not resolve), websearch for canonical documentation (prefer `llms.txt`, developer docs, official API references over tutorials or blog posts). Collect 3–5 candidate URLs before reading any. +Otherwise, for every topic area Context7 did not cover, websearch for canonical documentation — `llms.txt`, official developer docs, and API references ahead of tutorials or blog posts. Collect three to five candidate URLs before reading any of them. -4. **Read sources in parallel.** Spawn one subagent per source URL. Each subagent fetches the page, extracts relevant content, identifies links worth deepening, and returns a structured summary (content by topic area + links to follow). Subagents do not write files. +If nothing usable comes back, stop and report what was searched, then ask for starting URLs rather than settling for tutorials. -5. **Deepen.** For each subagent that returned links worth following, spawn child subagents per branch. Continue until content becomes repetitive or out of scope. Cap at ~10 additional pages total across all branches. +## Step 4 — Read the sources -6. **Consolidate.** Merge all subagent summaries (Context7 and web) by topic area. Identify which default topics have sufficient content and which custom topics emerged. +`WebFetch` each URL in turn. No subagent tool is granted here, so the reads are serial and every fetched page lands in this context: reduce each page to notes by topic area, plus the links worth deepening, before fetching the next one. -7. **Write topic files.** For each topic with content, write `/.md` using the format in `references/file-format.md`. Orchestrator writes all files — never delegate file writing to a subagent. +## Step 5 — Deepen -8. **Write `sources.md`.** Write `/sources.md` mapping each source slug to its URL (use `context7:` as the URL for Context7 sources), description, and list of topic files it contributed to. Include sources that yielded no content, marked `no content extracted`. +`WebFetch` the links worth following, still one at a time and still reducing each page to notes. Stop a branch once its content turns repetitive or leaves the topic, and cap the whole step at roughly ten additional pages — serial reads make that cap a real budget, not a formality. -## Output format +## Step 6 — Write -- `/.md` per topic with content — formatted per `references/file-format.md` -- `/sources.md` — always produced; maps slug → URL, description, contributing files +Merge every set of notes, Context7 and web alike, by topic area, then write, in the output path: - +- `.md` for each topic area that has content, default or custom. Frontmatter carries `topic:` (the filename without `.md`) and `source_keys:` (kebab-case slugs matching `sources.md`); the body is prose in `##` sections, with no inline URLs. +- `sources.md`, always, one `##` section per source — including sources that yielded nothing — with exactly these four fields: - + ```markdown + - **URL:** + - **Description:** + - **Contributing files:** + - **Status:** `extracted` | `no content extracted` + ``` -## Failure handling +Spell those four field names exactly as given. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -- Output path not provided — stop and ask; do not infer or default -- No sources found after websearch — report what was searched, ask user to provide starting URLs -- Subagent returns no usable content — skip that source, log in `sources.md` as `no content extracted` -- All topic files would be empty — stop, report what was searched, do not write any files +Read `references/file-format.md` when the four fields above do not settle the case: what a slug should be, the `context7-` slug and `context7:` URL convention for a Context7 source, or what belongs in a topic body versus a verbatim copy of the source. -## Self-check - -- [ ] Codebase scanned before any websearch was performed -- [ ] Output path was explicitly provided — not inferred -- [ ] `references/topics.md` and `references/file-format.md` read at step 1 -- [ ] Context7 resolution attempted before websearch when topic is a library/framework/API -- [ ] Context7 calls made only at orchestrator step 2 — no subagent called `resolve-library-id` or `query-docs` -- [ ] Context7 sources recorded in `sources.md` with `context7:` as URL -- [ ] No topic file written without content -- [ ] `sources.md` written with all sources read (including those with no content extracted) -- [ ] All file writes performed by the orchestrator, not subagents -- [ ] Each topic file has `topic` and `source_keys` frontmatter fields -- [ ] All source keys in topic files have a matching entry in `sources.md` -- [ ] No files written outside the provided output path - - +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/.apm/skills/tdd/README.md b/plugins/bin/.apm/skills/tdd/README.md new file mode 100644 index 0000000..d3b62e6 --- /dev/null +++ b/plugins/bin/.apm/skills/tdd/README.md @@ -0,0 +1,32 @@ +# tdd + +Test-driven development as a strict red-green-refactor loop, one behaviour at a time. + +## What it does + +Two convictions drive this skill. The first is about what a test is for: tests verify behaviour through public interfaces, not implementation details. A good test reads like a specification ("user can checkout with valid cart") and survives refactors because it does not care about internal structure. The warning sign for a bad one is precise — the test breaks when you refactor but behaviour has not changed. + +The second is an explicit anti-pattern: **do not write all the tests first, then all the implementation.** Horizontal slicing treats RED as "write every test" and GREEN as "write every implementation", and it produces tests of *imagined* behaviour — tests of the shape of things, insensitive to real change, committed to before the implementation was understood. The correct shape is vertical: one test → one implementation → repeat, each cycle informed by what the last one taught you. + +The workflow is four stages: plan (confirm the interface and which behaviours matter, with the user — you cannot test everything), fire a tracer bullet (one test proving the path works end to end), loop incrementally one behaviour at a time, then refactor once everything is green. Refactoring while RED is forbidden. + +Codebase exploration uses the project's domain glossary, so test names and interface vocabulary match the project's language, and ADRs in the area are respected. + +## Usage + +```text +/tdd +``` + +Describe the feature or bug. Expect the skill to ask what the public interface should look like and which behaviours matter most before any code is written. For diagnosing an existing bug rather than building test-first, use `diagnose`; for throwaway exploratory code, use `prototype`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Philosophy, the horizontal-slicing anti-pattern, the four-stage workflow, and the per-cycle checklist | +| `tests.md` | Skill-root document, cited from Philosophy: worked good and bad test examples | +| `mocking.md` | Skill-root document, cited from Philosophy: mock at system boundaries only, and what not to mock | +| `deep-modules.md` | Skill-root document, cited from stage 1: what a deep module is (small interface, large implementation) and why it is the design to aim for | +| `interface-design.md` | Skill-root document, cited from stage 1: designing interfaces for testability, starting with accepting dependencies rather than creating them | +| `refactoring.md` | Skill-root document, cited from stage 4: the refactor-candidate checklist — duplication, long methods, shallow modules, feature envy, primitive obsession | diff --git a/plugins/bin/.apm/skills/tdd/SKILL.md b/plugins/bin/.apm/skills/tdd/SKILL.md index 7a98941..ad3be57 100644 --- a/plugins/bin/.apm/skills/tdd/SKILL.md +++ b/plugins/bin/.apm/skills/tdd/SKILL.md @@ -1,6 +1,9 @@ --- name: tdd -description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +description: > + Use when the user wants a feature built or a bug fixed test-first, in a strict + red-green-refactor loop, one behaviour at a time. Not diagnosing an existing + bug -> `diagnose`. Not throwaway exploratory code -> `prototype`. --- # Test-Driven Development @@ -13,7 +16,7 @@ description: Test-driven development with red-green-refactor loop. Use when user **Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. -See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. +If you need worked examples of the difference — a behaviour-level test beside the implementation-coupled version of the same check — read `tests.md`. If a test needs a collaborator faked, read `mocking.md` before reaching for a mock. ## Anti-Pattern: Horizontal Slices diff --git a/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md b/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md index 2efecdf..a7e1aa8 100644 --- a/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md +++ b/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md @@ -1,6 +1,6 @@ # Writing Agent Briefs -An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. +An agent brief is a structured comment posted on an issue in the issue tracker when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. ## Principles @@ -27,7 +27,7 @@ Describe **what** the system should do, not **how** to implement it. The agent w The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. -- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Good:** "Querying the issue tracker for the `needs-triage` label returns issues that have been through initial classification" - **Bad:** "Triage should work correctly" ### Explicit scope boundaries diff --git a/plugins/bin/.apm/skills/triage/README.md b/plugins/bin/.apm/skills/triage/README.md new file mode 100644 index 0000000..d9f5da2 --- /dev/null +++ b/plugins/bin/.apm/skills/triage/README.md @@ -0,0 +1,35 @@ +# triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +## What it does + +Gives issue triage an explicit state model and a fixed set of moves. Every issue carries exactly one **category** role (`bug`, `enhancement`) and one **state** role (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); conflicting state roles are flagged to the maintainer before anything else happens. Unlabeled issues normally enter at `needs-triage`; `needs-info` returns there once the reporter replies. The maintainer can override at any point, and unusual transitions are questioned rather than executed silently. + +A run does one of three things depending on what the maintainer asks for: + +- **Show what needs attention** — three buckets, oldest first: unlabeled, `needs-triage`, and `needs-info` with reporter activity since the last triage notes. +- **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. +- **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. + +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — each is resolved against the tracker's live label set before it is applied, and a name with no counterpart there is reported to the maintainer as a gap rather than guessed at. + +## Composition + +`grill-with-docs` is invoked at step 4 when an issue needs fleshing out; whatever that session establishes is carried into the triage notes so the work is not lost. The reverse direction also exists: `diagnose` names this skill as the place to send a *reported* bug that needs filing rather than debugging. + +## Usage + +```text +/triage +``` + +Then describe what you want in natural language — "show me anything that needs my attention", "let's look at #42", "move #42 to ready-for-agent", "what's ready for agents to pick up?". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The roles and state machine, the three invocation modes, the needs-info template, and how to resume a prior session | +| `AGENT-BRIEF.md` | Skill-root document, cited when an issue moves to `ready-for-agent` (and reused for `ready-for-human`): how to write a brief that stays durable for weeks while the codebase moves under it — describe interfaces and behavioural contracts, not line numbers | +| `OUT-OF-SCOPE.md` | Skill-root document, cited when an enhancement is closed `wontfix` and when checking for prior rejections: how the `.out-of-scope/` knowledge base is laid out and what it is for — institutional memory, and deduplication against re-litigated requests | diff --git a/plugins/bin/.apm/skills/triage/SKILL.md b/plugins/bin/.apm/skills/triage/SKILL.md index 3dee68f..d83ab5e 100644 --- a/plugins/bin/.apm/skills/triage/SKILL.md +++ b/plugins/bin/.apm/skills/triage/SKILL.md @@ -1,6 +1,9 @@ --- name: triage -description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +description: > + Use when the user wants an issue created, triaged, or moved through the + tracker's triage states, or an issue prepared for an AFK agent. Not debugging + the bug itself -> `diagnose`. Not fleshing out a design -> `grill-with-docs`. --- # Triage @@ -35,7 +38,7 @@ Five **state** roles: Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. -These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. +These are canonical role names — the actual label strings used in the issue tracker may differ. Resolve each canonical name against the tracker's live label set before applying it, using whichever tracker skill this install provides. If a name has no counterpart there, report the gap and ask the maintainer for the mapping — never substitute a guess. State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. diff --git a/plugins/bin/.apm/skills/write-docs/README.md b/plugins/bin/.apm/skills/write-docs/README.md new file mode 100644 index 0000000..8ed0156 --- /dev/null +++ b/plugins/bin/.apm/skills/write-docs/README.md @@ -0,0 +1,30 @@ +# write-docs + +Produce technical documentation derived from code and spec, one section at a time, with a confirmation gate on every section. + +## What it does + +Casts the agent as a technical writer with one non-negotiable constraint: **every claim must be traceable to a source file line, a spec section, or an explicit user statement.** Nothing is invented, and behaviour that genuinely cannot be documented from the available sources is marked out-of-scope rather than explained away. + +The process is eight steps — identify scope, read and extract, gap check, draft section by section, confirmation gate, delta summary, reader testing, finalise — and several of them are deliberately gated on the human: + +- Files are read only after the user approves them by name. The skill may propose candidates; it waits. +- The **gap check** presents what the code does say and asks the user to fill only what it does not: caller intent, error-handling rationale, non-obvious side effects. +- No section is finalised until the full revised text has been shown. The skill never gates on output the user has not seen, and never reprints the whole document — all edits are surgical. +- **Reader testing** predicts 5–10 questions a target reader would ask, then spawns a sub-agent that receives only the finished doc and the questions — no source files. If the doc cannot answer them, neither can the sub-agent, and the run loops back to drafting. + +Summary and overview sections are written last, once the detail sections are stable. + +## Usage + +```text +/write-docs +``` + +Name the files or modules to document, the target audience (developer / user / contributor / internal), and the documentation type (reference, guide, README section, inline comment, changelog entry). For a PRD, ADR or decision doc, use `grill-me` or `grill-with-docs` instead — those have dedicated handling. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — role, use/do-not-use boundaries, required inputs, constraints, the eight-step process, output format, failure handling, and a nine-item self-check | diff --git a/plugins/bin/.apm/skills/write-docs/SKILL.md b/plugins/bin/.apm/skills/write-docs/SKILL.md index 0d2da2d..30ea51d 100644 --- a/plugins/bin/.apm/skills/write-docs/SKILL.md +++ b/plugins/bin/.apm/skills/write-docs/SKILL.md @@ -1,6 +1,11 @@ --- name: write-docs -description: Write documentation for X, document this module, create docs for this feature. Use when the user wants to produce or update technical documentation derived from code, spec, or existing artifacts. Do NOT use when the user wants a PRD, ADR, decision doc, or skill file — those have dedicated skills. +description: > + Use when the user wants technical documentation produced or updated from code + or spec, every claim traced to a source — "write docs for X", "document this + module", "create docs for this feature", "write a README for this". Not an ADR + or other decision record -> `grill-with-docs`. Not an external tool researched + from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec @@ -35,7 +40,8 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `to-prd` or `grill-me` +- User wants an ADR, decision doc, or architecture proposal → `grill-with-docs`, which writes ADRs +- User wants a PRD → no skill in this set produces one; say so rather than redirecting - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -88,7 +94,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `to-prd`, `grill-me`, or `grill-with-docs` +- Requested output is an ADR, decision doc, or architecture proposal → redirect to `grill-with-docs`; for a PRD, say no skill here produces one instead of redirecting ## Self-check diff --git a/plugins/bin/.apm/skills/zoom-out/README.md b/plugins/bin/.apm/skills/zoom-out/README.md new file mode 100644 index 0000000..ce71a99 --- /dev/null +++ b/plugins/bin/.apm/skills/zoom-out/README.md @@ -0,0 +1,25 @@ +# zoom-out + +Ask the agent to go up a layer of abstraction and map the modules and callers around unfamiliar code. + +## What it does + +A single-purpose prompt for the moment you land in a part of the codebase you do not know. Instead of answering at the level of the file in front of it, the agent climbs one layer and produces a map of the relevant modules and their callers — and names them using the project's own domain glossary vocabulary, so the map lines up with the language the rest of the repo already uses. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`, so the router never selects this skill on its own and no other skill can hand off to it. It runs when the human asks for it. That also means its description is written as one plain human-facing sentence — it carries no trigger phrasing or boundary clause, because nothing routes on it. + +## Usage + +```text +/zoom-out +``` + +Best used with the unfamiliar code already in context — the skill widens the view around what you are looking at rather than picking a starting point for you. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — a single instruction, no supporting files | diff --git a/plugins/bin/.claude-plugin/plugin.json b/plugins/bin/.claude-plugin/plugin.json index d3644c8..e1e71ee 100644 --- a/plugins/bin/.claude-plugin/plugin.json +++ b/plugins/bin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.1.5", + "version": "1.1.6", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/.github/plugin/plugin.json b/plugins/bin/.github/plugin/plugin.json index 18814d8..5f8e98a 100644 --- a/plugins/bin/.github/plugin/plugin.json +++ b/plugins/bin/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.1.5", + "version": "1.1.6", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/apm.yml b/plugins/bin/apm.yml index fd3c6d0..eec10b1 100644 --- a/plugins/bin/apm.yml +++ b/plugins/bin/apm.yml @@ -1,5 +1,5 @@ name: bin -version: 1.1.5 +version: 1.1.6 description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. author: name: Defame1297 diff --git a/plugins/bin/evals/research/research/eval.yaml b/plugins/bin/evals/research/research/eval.yaml index 1f402f1..39ad9c6 100644 --- a/plugins/bin/evals/research/research/eval.yaml +++ b/plugins/bin/evals/research/research/eval.yaml @@ -26,11 +26,6 @@ trigger_tests: query: "Research why these integration tests are failing" should_trigger: false - - id: negative-neuledge - name: "Negative — MCP server setup goes to neuledge-context" - query: "Install the neuledge context server and set it up" - should_trigger: false - - id: negative-context7-direct-question name: "Negative — direct doc question goes to context7-mcp, not research" query: "What are the Next.js middleware options?" diff --git a/plugins/bin/skills/caveman/README.md b/plugins/bin/skills/caveman/README.md new file mode 100644 index 0000000..63ba67c --- /dev/null +++ b/plugins/bin/skills/caveman/README.md @@ -0,0 +1,29 @@ +# caveman + +Ultra-compressed output mode: drop articles, filler and pleasantries, keep the technical substance exact. + +## What it does + +Switches the agent into a terse register — no articles, no hedging, no pleasantries, fragments allowed, arrows for causality — while leaving technical terms, code blocks and quoted error strings untouched. The mode is *sticky*: once turned on it stays on for every subsequent response until the user says "stop caveman" or "normal mode", rather than decaying back to normal prose after a few turns. + +It carries one built-in escape hatch. Security warnings, confirmations for irreversible actions, multi-step sequences where fragment order could be misread, and any request to clarify are answered in normal prose, then the compressed register resumes. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`. This is the single most important thing to know about this skill: **the model cannot route to it.** No other skill can hand off to it, and no phrasing in a user's request will cause it to be selected automatically. The only way in is the human typing `/caveman`. + +That is deliberate — output style is the user's choice, not an inference the router should make on their behalf. It is also why the description reads as one plain human-facing sentence rather than carrying the trigger phrasing and boundary clause a routable skill needs. + +## Usage + +```text +/caveman +``` + +Then keep working normally. To leave the mode, say "stop caveman" or "normal mode". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — persistence rule, compression rules, worked examples, and the auto-clarity exception | diff --git a/plugins/bin/skills/caveman/SKILL.md b/plugins/bin/skills/caveman/SKILL.md index 85770a3..dea5b4a 100644 --- a/plugins/bin/skills/caveman/SKILL.md +++ b/plugins/bin/skills/caveman/SKILL.md @@ -1,17 +1,16 @@ --- name: caveman +disable-model-invocation: true description: > - Ultra-compressed communication mode. Cuts token usage ~75% by dropping - filler, articles, and pleasantries while keeping full technical accuracy. - Use when user says "caveman mode", "talk like caveman", "use caveman", - "less tokens", "be brief", or invokes /caveman. + Ultra-compressed output mode that drops articles, filler and pleasantries while + keeping technical substance exact, cutting token usage by roughly 75%. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. ## Persistence -ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". +ACTIVE EVERY RESPONSE once user type `/caveman`. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". ## Rules diff --git a/plugins/bin/skills/diagnose/README.md b/plugins/bin/skills/diagnose/README.md new file mode 100644 index 0000000..6e2c414 --- /dev/null +++ b/plugins/bin/skills/diagnose/README.md @@ -0,0 +1,35 @@ +# diagnose + +A six-phase discipline for hard bugs and performance regressions: feedback loop → reproduce → hypothesise → instrument → fix with a regression test → clean up. + +## What it does + +Imposes an order of operations on debugging so the agent cannot skip to guessing. The load-bearing phase is the first one: build a fast, deterministic, agent-runnable pass/fail signal for the bug. Everything downstream — bisection, hypothesis testing, instrumentation — just consumes that signal, so the skill refuses to advance to Phase 2 without one, and says so explicitly rather than hypothesising blind. + +The remaining phases each carry a constraint worth knowing about: hypotheses are generated 3–5 at a time and must be falsifiable, so the first plausible idea cannot anchor the whole investigation; every debug log is tagged with a unique prefix (`[DEBUG-a4f2]`) so cleanup is a single grep; the regression test is written before the fix and only at a seam that exercises the real bug pattern; and the run closes by asking what would have prevented the bug, handing off to `improve-codebase-architecture` when the answer is architectural. + +Performance regressions take a branch of their own inside Phase 4 — baseline measurement and bisection, not logs. + +## Conditional reading + +Neither reference file is read on every run; `SKILL.md` names the condition for each. + +- `references/feedback-loops.md` is read when Phase 1 has no signal yet, or when the loop you have is slow or intermittent. +- `references/regression-seams.md` is read when Phase 5 leaves you unsure whether the available seam is deep enough — or whether one exists at all. + +## Usage + +```text +/diagnose +``` + +Describe the bug or the regression. For filing and triaging a reported bug rather than diagnosing it, use `triage`; for test-first feature work, use `tdd`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The six phases and their gates — what must be true before each one ends | +| `references/feedback-loops.md` | Loaded when Phase 1 has no loop or the loop is too weak: ten ways to construct one ordered by cost, how to sharpen an existing loop, handling intermittent bugs, and what to ask the user for when the bug resists reproduction | +| `references/regression-seams.md` | Loaded when Phase 5 is unsure about the seam: what makes a seam correct, the four shapes of a too-shallow seam, and what to do when no correct seam exists | +| `assets/hitl-loop.template.sh` | Copy-and-edit bash template for the last-resort human-in-the-loop feedback loop, cited by `references/feedback-loops.md`. Provides `step` and `capture` helpers and prints captured values as `KEY=VALUE` for the agent to parse | diff --git a/plugins/bin/skills/diagnose/SKILL.md b/plugins/bin/skills/diagnose/SKILL.md index ed55bda..1e60479 100644 --- a/plugins/bin/skills/diagnose/SKILL.md +++ b/plugins/bin/skills/diagnose/SKILL.md @@ -1,6 +1,9 @@ --- name: diagnose -description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +description: > + Use when the user says "diagnose this" or "debug this", reports something + broken, throwing, or failing, or says something got slow. Not filing or + triaging a reported bug -> `triage`. Not test-first feature work -> `tdd`. --- # Diagnose @@ -15,40 +18,11 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -### Ways to construct one — try them in roughly this order +**If you do not yet have such a signal, read `references/feedback-loops.md`** — ten ways to build one ordered by cost, and what to ask the user for when the bug resists reproduction entirely. -1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. -2. **Curl / HTTP script** against a running dev server. -3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. -4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. -5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. -6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. -7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. -8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. -9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +**If you do have one, it is probably not sharp enough yet.** Make it faster and more deterministic, and make it assert on the exact symptom rather than "didn't crash" — a 30-second flaky loop is barely better than no loop. If it stays slow or intermittent after that, read that file's "Iterate on the loop itself" and "Intermittent bugs" sections. -Build the right feedback loop, and the bug is 90% fixed. - -### Iterate on the loop itself - -Treat the loop as a product. Once you have _a_ loop, ask: - -- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) -- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) -- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) - -A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. - -### Non-deterministic bugs - -The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. - -### When you genuinely cannot build a loop - -Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. - -Do not proceed to Phase 2 until you have a loop you believe in. +Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. ## Phase 2 — Reproduce @@ -57,7 +31,7 @@ Run the loop. Watch the bug appear. Confirm: - [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. -- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] The failure is reproducible across multiple runs. If it is intermittent, `references/feedback-loops.md` defines the rate high enough to debug against — go back to Phase 1 and raise it. - [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. Do not proceed until you reproduce the bug. @@ -90,19 +64,17 @@ Tool preference: ## Phase 5 — Fix + regression test -Write the regression test **before the fix** — but only if there is a **correct seam** for it. +Write the regression test **before the fix** — but only at a **correct seam**: one where the test exercises the real bug pattern as it occurs at the call site. If the available seam looks too shallow, or you cannot tell whether it is, read `references/regression-seams.md`. -A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. +**If no correct seam exists, that itself is the finding.** Note it and carry it into Phase 6 — the architecture is preventing the bug from being locked down. -**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. +At a correct seam: -If a correct seam exists: - -1. Turn the minimised repro into a failing test at that seam. +1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. 3. Apply the fix. 4. Watch it pass. -5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. +5. Re-run the Phase 1 feedback loop against the original, un-narrowed scenario. ## Phase 6 — Cleanup + post-mortem diff --git a/plugins/bin/skills/diagnose/scripts/hitl-loop.template.sh b/plugins/bin/skills/diagnose/assets/hitl-loop.template.sh similarity index 100% rename from plugins/bin/skills/diagnose/scripts/hitl-loop.template.sh rename to plugins/bin/skills/diagnose/assets/hitl-loop.template.sh diff --git a/plugins/bin/skills/diagnose/references/feedback-loops.md b/plugins/bin/skills/diagnose/references/feedback-loops.md new file mode 100644 index 0000000..8804543 --- /dev/null +++ b/plugins/bin/skills/diagnose/references/feedback-loops.md @@ -0,0 +1,40 @@ +# Constructing and sharpening a feedback loop + +A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for the bug. Build the right one and the bug is 90% fixed. This file covers the whole arc: building a loop, sharpening one you already have, and escalating when the bug resists reproduction. + +## Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `assets/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +## Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +## Intermittent bugs — raise the reproduction rate + +If the loop only sometimes fails, the goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +## When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: + +- access to whatever environment reproduces it, +- a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or +- permission to add temporary production instrumentation. + +Do **not** proceed to hypothesise without a loop. A hypothesis you cannot falsify against a signal is a guess, and the fix that follows it is unverifiable. diff --git a/plugins/bin/skills/diagnose/references/regression-seams.md b/plugins/bin/skills/diagnose/references/regression-seams.md new file mode 100644 index 0000000..26176a8 --- /dev/null +++ b/plugins/bin/skills/diagnose/references/regression-seams.md @@ -0,0 +1,24 @@ +# Judging a regression-test seam + +Read this when Phase 5 leaves you unsure whether the seam available for the regression test is the correct one — either because the obvious seam looks shallow, or because there appears to be no seam at all. + +## What makes a seam correct + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site: the same entry point, the same participants, the same ordering, and the same state the real caller holds when it goes wrong. + +## Seams that are too shallow + +- A single-caller test when the bug only appears with multiple callers. +- A unit test that cannot replicate the chain of calls that triggered the bug. +- A test that reproduces the symptom by construction — asserting on a value the test itself set — rather than by driving the code path that produces it. +- A test that mocks out the collaborator the bug actually lives in. + +A regression test at a shallow seam gives false confidence. It passes forever, including after a change reintroduces the bug at the real call site, and it will be read by the next maintainer as proof the bug is locked down. + +## When there is no correct seam + +Do not force one, and do not settle for a shallow seam to have something green. Instead: + +1. Apply the fix and verify it against the Phase 1 loop directly. +2. Write down which seams you considered and why each was too shallow. +3. Carry that into Phase 6's "what would have prevented this bug" question. A missing seam is an architecture finding — tangled callers, hidden coupling, or a module with no testable boundary — and the handoff is the `improve-codebase-architecture` skill, with those specifics attached. diff --git a/plugins/bin/skills/grill-me/README.md b/plugins/bin/skills/grill-me/README.md new file mode 100644 index 0000000..6197081 --- /dev/null +++ b/plugins/bin/skills/grill-me/README.md @@ -0,0 +1,27 @@ +# grill-me + +Interview the user relentlessly about a plan or design until the decision tree is fully resolved. + +## What it does + +Turns the agent into an interviewer rather than an implementer. It walks the design tree branch by branch, resolving dependencies between decisions one at a time, and offers its own recommended answer alongside each question so the user has something concrete to push against. Two rules give it its shape: **one question at a time**, and **never ask what the codebase can answer** — if a question is settleable by reading the code, the agent goes and reads the code instead of spending the user's attention on it. + +## Composition + +This is the plain grilling loop, with no documentation side effects. The sibling `grill-with-docs` skill runs the same interview but additionally challenges answers against the project's `CONTEXT.md` glossary and existing ADRs, and writes decisions back into those files as they crystallise. Reach for that one when the project has a domain model worth defending; reach for this one when it does not, or when nothing should be written down yet. + +`triage` composes the documented variant, not this one, when an issue needs fleshing out. + +## Usage + +```text +/grill-me +``` + +Describe the plan or design to be stress-tested. Expect questions one at a time, each with a recommended answer. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — the interview instruction, the one-question-at-a-time rule, and the explore-instead-of-asking rule | diff --git a/plugins/bin/skills/grill-me/SKILL.md b/plugins/bin/skills/grill-me/SKILL.md index bd04394..1cc8ede 100644 --- a/plugins/bin/skills/grill-me/SKILL.md +++ b/plugins/bin/skills/grill-me/SKILL.md @@ -1,6 +1,10 @@ --- name: grill-me -description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +description: > + Use when the user says "grill me" or wants a plan or design stress-tested by + relentless interview — one question at a time, down each branch of the + decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs -> + `grill-with-docs`. --- Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. diff --git a/plugins/bin/skills/grill-with-docs/README.md b/plugins/bin/skills/grill-with-docs/README.md new file mode 100644 index 0000000..1ea62ce --- /dev/null +++ b/plugins/bin/skills/grill-with-docs/README.md @@ -0,0 +1,37 @@ +# grill-with-docs + +The grilling interview, run against the project's domain model — and writing decisions back into `CONTEXT.md` and ADRs as they land. + +## What it does + +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it five ways: + +- **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. +- **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Stress-tests domain relationships with concrete scenarios**, inventing edge cases that force the user to be precise about where one concept ends and the next begins. +- **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. +- **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. + +Files are created lazily — only when there is something real to write. + +ADRs are offered *sparingly*, and only when all three tests pass: the decision is hard to reverse, it would surprise a future reader without the context, and it was a genuine trade-off with real alternatives. Missing any one of the three means no ADR. + +## Composition + +`grill-me` is the same interview without the documentation side effects — use it when there is no domain model to defend or nothing should be written down yet. `triage` composes this skill (not `grill-me`) at step 4 when an issue needs fleshing out. `improve-codebase-architecture` runs its own grilling loop and borrows this skill's `CONTEXT.md` and ADR discipline for the decisions that come out of it. + +## Usage + +```text +/grill-with-docs +``` + +Describe the plan or design. Expect questions one at a time, each with a recommended answer, and expect `CONTEXT.md` to be edited during the session rather than after it. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the five during-session behaviours, and the three-part ADR test | +| `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | +| `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/skills/grill-with-docs/SKILL.md b/plugins/bin/skills/grill-with-docs/SKILL.md index 6dad6ad..8610263 100644 --- a/plugins/bin/skills/grill-with-docs/SKILL.md +++ b/plugins/bin/skills/grill-with-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: grill-with-docs -description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +description: > + Use when a plan should be stress-tested against the project's domain model — + the interview challenges terms against `CONTEXT.md` and writes decisions into + it and into ADRs as they land. Not a plain interview -> `grill-me`. --- diff --git a/plugins/bin/skills/improve-codebase-architecture/README.md b/plugins/bin/skills/improve-codebase-architecture/README.md new file mode 100644 index 0000000..509cad7 --- /dev/null +++ b/plugins/bin/skills/improve-codebase-architecture/README.md @@ -0,0 +1,36 @@ +# improve-codebase-architecture + +Surface architectural friction and propose deepening opportunities — refactors that turn shallow modules into deep ones. + +## What it does + +Looks for places where a codebase is hard to understand, hard to test, or hard for an agent to navigate, and proposes refactors that concentrate behaviour behind smaller interfaces. It runs in three stages: + +1. **Explore.** Reads the domain glossary and any ADRs in the area first, then walks the codebase with an `Explore` sub-agent — organically, noting friction rather than applying fixed heuristics. The **deletion test** is the filter: imagine deleting the module; if complexity vanishes it was a pass-through, if complexity reappears across N callers it was earning its keep. +2. **Present candidates.** A numbered list, each with files, problem, solution and benefits — benefits stated in terms of *locality* and *leverage* and of how tests would improve. No interfaces are proposed yet; the user picks one. +3. **Grilling loop.** Walks the design tree for the chosen candidate, with documentation side effects landing inline as decisions crystallise. + +The skill is opinionated about vocabulary, and that is the point: **module, interface, implementation, depth, seam, adapter, leverage, locality**, used exactly, with no drift into "component", "service", "API" or "boundary". Domain nouns come from `CONTEXT.md`, architecture nouns from `LANGUAGE.md` — so a proposal reads as "the Order intake module", never "the FooBarHandler". + +ADRs are treated as decisions not to be re-litigated. A candidate that contradicts one is surfaced only when the friction is real enough to warrant reopening it, and is marked as such. + +## Composition + +`diagnose` hands off here when a bug's post-mortem concludes that no correct test seam exists, or that callers are tangled — the recommendation is made after the fix is in, not before. The grilling loop follows `grill-with-docs`'s discipline for `CONTEXT.md` entries and ADR offers, and `SKILL.md` names that skill's format documents directly. + +## Usage + +```text +/improve-codebase-architecture +``` + +Point at a codebase or an area of one. Expect a numbered candidate list and a "which of these would you like to explore?" before any interface design happens. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Condensed glossary, key principles, and the three-stage process | +| `LANGUAGE.md` | Skill-root document, cited throughout `SKILL.md`: full definitions of every term, the words each one replaces, and the full principle list | +| `INTERFACE-DESIGN.md` | Skill-root document, read at stage 3 when the user wants alternative interfaces explored: the parallel sub-agent "Design It Twice" pattern, framing the problem space, and the per-agent design constraints | +| `DEEPENING.md` | Skill-root document, cited from `INTERFACE-DESIGN.md`: how to deepen a cluster of shallow modules safely, the four dependency categories (in-process, local-substitutable, remote-but-owned, true external), seam discipline, and the replace-don't-layer testing strategy | diff --git a/plugins/bin/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/skills/improve-codebase-architecture/SKILL.md index 05984a6..750876a 100644 --- a/plugins/bin/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/skills/improve-codebase-architecture/SKILL.md @@ -1,6 +1,11 @@ --- name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +description: > + Use when the user wants to improve architecture, find refactoring + opportunities, consolidate tightly-coupled modules, or make a codebase more + testable and AI-navigable — deepening opportunities that turn shallow modules + into deep ones, informed by `CONTEXT.md` and `docs/adr/`. Not debugging a + failure -> `diagnose`. --- # Improve Codebase Architecture @@ -9,7 +14,7 @@ Surface architectural friction and propose **deepening opportunities** — refac ## Glossary -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." - **Module** — anything with an interface and an implementation (function, class, package, slice). - **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. @@ -20,12 +25,14 @@ Use these terms exactly in every suggestion. Consistent language is the point - **Leverage** — what callers get from depth. - **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): +Key principles: - **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. - **The interface is the test surface.** - **One adapter = hypothetical seam. Two adapters = real seam.** +If a term or principle above is ambiguous in the case in front of you, or you need the definitions and the principles the two lists leave out, read `LANGUAGE.md`. + This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. ## Process @@ -53,7 +60,7 @@ Present a numbered list of deepening opportunities. For each candidate: - **Solution** — plain English description of what would change - **Benefits** — explained in terms of locality and leverage, and also in how tests would improve -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." +**Use CONTEXT.md vocabulary for the domain, and the architecture glossary above for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." **ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. @@ -65,7 +72,7 @@ Once the user picks a candidate, drop into a grilling conversation. Walk the des Side effects happen inline as decisions crystallize: -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. +- **Want to explore alternative interfaces for the deepened module?** Read `INTERFACE-DESIGN.md`. diff --git a/plugins/bin/skills/prototype/README.md b/plugins/bin/skills/prototype/README.md new file mode 100644 index 0000000..425a6e8 --- /dev/null +++ b/plugins/bin/skills/prototype/README.md @@ -0,0 +1,32 @@ +# prototype + +Build a throwaway prototype that answers one design question — either a runnable terminal app or several UI variations. + +## What it does + +Treats a prototype as **throwaway code that answers a question**, and lets the question decide the artifact. `SKILL.md` opens with a two-row dispatch table and the run resolves exactly one row before doing anything else: + +- *"Does this logic / state model feel right?"* → a tiny interactive terminal app that pushes the state machine through the cases that are hard to reason about on paper. +- *"What should this look like?"* → several radically different UI variations on one route, switchable from a floating bottom bar via a URL search param. + +The two branches produce fundamentally different artifacts, so picking wrong wastes the whole prototype. When the question is genuinely ambiguous and the user is unreachable, the skill defaults on the shape of the surrounding code (backend module → logic, page or component → UI) and states the assumption at the top of the prototype rather than silently choosing. + +Six rules apply to both branches: throwaway and visibly named as such, one command to run, no persistence by default, no polish, surface the full state after every action or variant switch, and delete or absorb the prototype when it is done. The *answer* is the only durable output — the skill captures it in a commit message, ADR, issue or `NOTES.md` before the code is deleted. + +## Usage + +```text +/prototype +``` + +State the design question. For production code, use `tdd`; for talking a design through without building anything, use `grill-me`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The branch dispatch table and the rules that apply to both branches | +| `references/logic.md` | The logic branch, read only when that row is selected: when it is the right shape, and how to build the interactive terminal app | +| `references/ui.md` | The UI branch, read only when that row is selected: when it is the right shape, and how to build and switch between the variations | + +Each reference is self-contained — a run reads one of the two, never both. diff --git a/plugins/bin/skills/prototype/SKILL.md b/plugins/bin/skills/prototype/SKILL.md index 84478e0..0c9d8e3 100644 --- a/plugins/bin/skills/prototype/SKILL.md +++ b/plugins/bin/skills/prototype/SKILL.md @@ -1,6 +1,10 @@ --- name: prototype -description: Build a throwaway prototype to flush out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs". +description: > + Use when the user wants a throwaway prototype to answer a design question about + a data model, state machine or business logic, or to mock up a UI in several + variations. Not production code -> `tdd`. Not talking a design through -> + `grill-me`. --- # Prototype @@ -9,10 +13,12 @@ A prototype is **throwaway code that answers a question**. The question decides ## Pick a branch -Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: +| Question being answered | Build | Reference | +|---|---|---| +| "Does this logic / state model feel right?" | A tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper | `references/logic.md` | +| "What should this look like?" | Several radically different UI variations on one route, switchable via a URL search param and a floating bottom bar | `references/ui.md` | -- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. -- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. +Resolve the row from the user's prompt, the surrounding code, or by asking if the user is around, then read only that reference — each is self-contained. The two branches produce fundamentally different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. diff --git a/plugins/bin/skills/prototype/LOGIC.md b/plugins/bin/skills/prototype/references/logic.md similarity index 99% rename from plugins/bin/skills/prototype/LOGIC.md rename to plugins/bin/skills/prototype/references/logic.md index 526ecb1..2376fd1 100644 --- a/plugins/bin/skills/prototype/LOGIC.md +++ b/plugins/bin/skills/prototype/references/logic.md @@ -9,7 +9,7 @@ A tiny interactive terminal app that lets the user drive a state model by hand. - "I want to feel out what the API should look like before writing it." - Anything where the user wants to **press buttons and watch state change**. -If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). +If the question is "what should this look like" — wrong branch. Read `references/ui.md`. ## Process diff --git a/plugins/bin/skills/prototype/UI.md b/plugins/bin/skills/prototype/references/ui.md similarity index 99% rename from plugins/bin/skills/prototype/UI.md rename to plugins/bin/skills/prototype/references/ui.md index f3b6e64..9afb121 100644 --- a/plugins/bin/skills/prototype/UI.md +++ b/plugins/bin/skills/prototype/references/ui.md @@ -2,7 +2,7 @@ Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. -If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). +If the question is about logic/state rather than what something looks like — wrong branch. Read `references/logic.md`. ## When this is the right shape diff --git a/plugins/bin/skills/research/META.md b/plugins/bin/skills/research/META.md deleted file mode 100644 index 824f4c3..0000000 --- a/plugins/bin/skills/research/META.md +++ /dev/null @@ -1,15 +0,0 @@ -```yaml -version: "1.1" -updated: 2026-06-21 - -when: >- - Invoked when the user wants to gather structured reference documentation for a - tool, library, or API from MCP documentation indexes or web sources. Typically - run before writing a new skill that wraps an external tool, or any time - reference files are needed for a topic. Triggered explicitly - ("/research ") or implicitly when the user asks to look up, - gather, or pull docs for a topic before implementing something. - -references: - - .agents/skills/context7-mcp/SKILL.md # context7-mcp — MCP source channel integrated at step 2 -``` diff --git a/plugins/bin/skills/research/README.md b/plugins/bin/skills/research/README.md new file mode 100644 index 0000000..976cd2f --- /dev/null +++ b/plugins/bin/skills/research/README.md @@ -0,0 +1,31 @@ +# research + +Research a tool, library or API from canonical documentation into a directory of structured per-topic reference files. + +## What it does + +Runs a six-step pipeline: scope against the working directory (what version is actually in use, what is already documented), resolve the topic through Context7, websearch for canonical docs covering whatever Context7 missed, read those sources, deepen one level into the links worth following, then write one markdown file per topic area plus a `sources.md` provenance record. + +Four gotchas at the top of `SKILL.md` shape the whole run, and each exists because of a specific failure: the output path is never inferred (a guessed destination scatters a directory's worth of files through someone's source tree); nothing is written outside that path; no empty topic file is ever written (a stub `troubleshooting.md` reads downstream as researched and closed); and a Context7 "no results", redirect or header-only response does not count as coverage. If no topic area has content, the run writes nothing at all — `sources.md` included — and reports what it searched. + +The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably it grants no subagent tool, so every `WebFetch` is serial and each fetched page lands in the run's own context — which is why steps 4 and 5 insist on reducing each page to notes before fetching the next, and cap deepening at roughly ten extra pages. + +## Composition + +Both reference files are read on condition, never on every run — `SKILL.md` inlines the minimum each step needs (the seven default topic areas at step 1, the four `sources.md` field names and the topic-file frontmatter keys at step 6) and sends the run to the reference only for what it does not carry. Those four field names are matched literally by the downstream provenance validator, so prose written in their place parses as nothing and the check passes having verified nothing — which is why they are inlined rather than deferred. + +## Usage + +```text +/research +``` + +Name the topic and the output path — the skill will stop and ask if the path is missing. Supplying starting URLs is treated as a deliberate source choice and skips Context7 resolution and discovery. For documentation derived from existing code or specs, use `write-docs`; for a bug or incident, use `diagnose`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The four gotchas and the six research steps | +| `references/topics.md` | Read at Step 1 only when what belongs in a default topic is unclear or a custom topic is needed: the per-topic coverage table and the custom-topic naming rule | +| `references/file-format.md` | Read at Step 6 only when the inlined field names do not settle the case: slug derivation, the Context7 slug and URL convention, and what belongs in a topic body | diff --git a/plugins/bin/skills/research/SKILL.md b/plugins/bin/skills/research/SKILL.md index 1453ddb..19fffee 100644 --- a/plugins/bin/skills/research/SKILL.md +++ b/plugins/bin/skills/research/SKILL.md @@ -1,97 +1,78 @@ --- name: research description: >- - Use when the user wants to research a topic and generate structured reference - markdown files. Handles: finding canonical docs for a tool/library/API via - Context7 MCP or web sources, reading and deepening into linked pages, - organizing extracted content into topic files (overview, installation, - configuration, cli-reference, api-reference, examples, troubleshooting). Do - NOT use when the user wants to write documentation from existing code or specs - (use write-docs), install or manage the neuledge-context MCP server (use - neuledge-context), or research a bug/incident (use diagnose). + Use when the user wants a tool, library, or API researched from canonical + documentation into structured per-topic reference markdown files. Not + documentation written from existing code or specs -> `write-docs`. Not a bug + or incident -> `diagnose`. metadata: category: research allowed-tools: - - WebSearch - - WebFetch + - Grep + - Glob - Read - Write + - WebSearch + - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs model: sonnet --- - +## Gotchas -## Required inputs +- Never infer the output path. A run writes a directory's worth of files, and a guessed destination scatters them through someone's source tree. If the user named no path, stop and ask. +- Write nothing outside the given output path. A file placed beside the agreed directory is one the user never asked for and will not think to look for. +- Never write an empty topic file. A stub `troubleshooting.md` reads downstream as researched and closed. +- A Context7 response that is a "no results" message, a redirect notice, or header-only boilerplate is not coverage. A topic area counts as covered only when the response carries at least one substantive paragraph. -- **Topic** — the subject to research (tool, library, API, concept); inferred from user description if clear, ask if ambiguous -- **Output path** — directory where reference files will be written; must be provided explicitly — do not infer or default -- **Starting URLs** — optional; if provided, skip discovery websearch and read these first +## Step 1 — Scope against the working directory -## Constraints +Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -- Never write files outside the explicitly provided output path -- Skip any default topic file if no relevant content is found for it — do not create empty files -- Create additional topic files beyond the default list when content warrants it (e.g. `webhooks.md`, `rate-limits.md`) -- Subagents handle parallel source reading and link deepening — the orchestrator writes all files; subagents return summaries only, never write directly -- Context7 MCP calls (`resolve-library-id`, `query-docs`) are made only by the orchestrator at step 2 — subagents must not call them -- `sources.md` is always written, even if only one source was read -- Each topic file must have frontmatter with `topic` and `source_keys`; body is prose only — no inline URLs -- Source keys in `sources.md` must be kebab-case slugs: derived from the source domain or page title for web sources; for Context7 sources use `context7-` (e.g. `context7-vercel-next-js`) -- Default topic list and file format spec live in `references/` sub-files — read them at step 1 +The default topic areas are `overview`, `installation`, `configuration`, `cli-reference`, +`api-reference`, `examples` and `troubleshooting` — one file each, and only where content exists. +If what belongs in one of them is unclear, or the topic needs a file outside that set, read +`references/topics.md` for the per-topic coverage table and the custom-topic naming rule. - +## Step 2 — Resolve against Context7 - +If the topic is a library, framework, or API and the user gave no starting URLs, call `resolve-library-id` with the topic name and the user's full question — match quality depends on the question, not the bare name — then `query-docs` once per default topic area. Record each response as a source with slug `context7-`, and mark which topic areas it covered — those skip the web reads at step 4. -## Process +If the library does not resolve, or the user gave starting URLs, go to step 3. Explicit URLs are a source choice; do not second-guess them with a resolution attempt. -1. **Scan codebase.** Search the working directory for existing usage of the topic — imports, config files, version pins, existing reference files. Use findings to narrow research scope (e.g. target the version already in use, skip topics already documented). Read `references/topics.md` for the default topic list and `references/file-format.md` for the output file format spec. +## Step 3 — Discover sources -2. **Try Context7.** If the topic is a library, framework, or API and no starting URLs were provided, call `resolve-library-id` with the topic name and the user's question. If a match resolves, call `query-docs` once per default topic area (see `references/topics.md`). Treat each response as a source summary with slug `context7-` (e.g. `context7-vercel-next-js`). A topic area has sufficient content when the Context7 response contains at least one substantive paragraph — not a "no results" message, redirect notice, or header-only boilerplate. Mark covered topic areas — skip their subagent web reads in step 4. If the library does not resolve, or starting URLs were provided (explicit source choice by the user), skip this step entirely. +If the user gave starting URLs, skip discovery: those URLs are the source list and go straight to step 4. -3. **Discover sources.** For topics not covered by Context7 (or when no starting URLs were provided and Context7 did not resolve), websearch for canonical documentation (prefer `llms.txt`, developer docs, official API references over tutorials or blog posts). Collect 3–5 candidate URLs before reading any. +Otherwise, for every topic area Context7 did not cover, websearch for canonical documentation — `llms.txt`, official developer docs, and API references ahead of tutorials or blog posts. Collect three to five candidate URLs before reading any of them. -4. **Read sources in parallel.** Spawn one subagent per source URL. Each subagent fetches the page, extracts relevant content, identifies links worth deepening, and returns a structured summary (content by topic area + links to follow). Subagents do not write files. +If nothing usable comes back, stop and report what was searched, then ask for starting URLs rather than settling for tutorials. -5. **Deepen.** For each subagent that returned links worth following, spawn child subagents per branch. Continue until content becomes repetitive or out of scope. Cap at ~10 additional pages total across all branches. +## Step 4 — Read the sources -6. **Consolidate.** Merge all subagent summaries (Context7 and web) by topic area. Identify which default topics have sufficient content and which custom topics emerged. +`WebFetch` each URL in turn. No subagent tool is granted here, so the reads are serial and every fetched page lands in this context: reduce each page to notes by topic area, plus the links worth deepening, before fetching the next one. -7. **Write topic files.** For each topic with content, write `/.md` using the format in `references/file-format.md`. Orchestrator writes all files — never delegate file writing to a subagent. +## Step 5 — Deepen -8. **Write `sources.md`.** Write `/sources.md` mapping each source slug to its URL (use `context7:` as the URL for Context7 sources), description, and list of topic files it contributed to. Include sources that yielded no content, marked `no content extracted`. +`WebFetch` the links worth following, still one at a time and still reducing each page to notes. Stop a branch once its content turns repetitive or leaves the topic, and cap the whole step at roughly ten additional pages — serial reads make that cap a real budget, not a formality. -## Output format +## Step 6 — Write -- `/.md` per topic with content — formatted per `references/file-format.md` -- `/sources.md` — always produced; maps slug → URL, description, contributing files +Merge every set of notes, Context7 and web alike, by topic area, then write, in the output path: - +- `.md` for each topic area that has content, default or custom. Frontmatter carries `topic:` (the filename without `.md`) and `source_keys:` (kebab-case slugs matching `sources.md`); the body is prose in `##` sections, with no inline URLs. +- `sources.md`, always, one `##` section per source — including sources that yielded nothing — with exactly these four fields: - + ```markdown + - **URL:** + - **Description:** + - **Contributing files:** + - **Status:** `extracted` | `no content extracted` + ``` -## Failure handling +Spell those four field names exactly as given. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -- Output path not provided — stop and ask; do not infer or default -- No sources found after websearch — report what was searched, ask user to provide starting URLs -- Subagent returns no usable content — skip that source, log in `sources.md` as `no content extracted` -- All topic files would be empty — stop, report what was searched, do not write any files +Read `references/file-format.md` when the four fields above do not settle the case: what a slug should be, the `context7-` slug and `context7:` URL convention for a Context7 source, or what belongs in a topic body versus a verbatim copy of the source. -## Self-check - -- [ ] Codebase scanned before any websearch was performed -- [ ] Output path was explicitly provided — not inferred -- [ ] `references/topics.md` and `references/file-format.md` read at step 1 -- [ ] Context7 resolution attempted before websearch when topic is a library/framework/API -- [ ] Context7 calls made only at orchestrator step 2 — no subagent called `resolve-library-id` or `query-docs` -- [ ] Context7 sources recorded in `sources.md` with `context7:` as URL -- [ ] No topic file written without content -- [ ] `sources.md` written with all sources read (including those with no content extracted) -- [ ] All file writes performed by the orchestrator, not subagents -- [ ] Each topic file has `topic` and `source_keys` frontmatter fields -- [ ] All source keys in topic files have a matching entry in `sources.md` -- [ ] No files written outside the provided output path - - +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/skills/tdd/README.md b/plugins/bin/skills/tdd/README.md new file mode 100644 index 0000000..d3b62e6 --- /dev/null +++ b/plugins/bin/skills/tdd/README.md @@ -0,0 +1,32 @@ +# tdd + +Test-driven development as a strict red-green-refactor loop, one behaviour at a time. + +## What it does + +Two convictions drive this skill. The first is about what a test is for: tests verify behaviour through public interfaces, not implementation details. A good test reads like a specification ("user can checkout with valid cart") and survives refactors because it does not care about internal structure. The warning sign for a bad one is precise — the test breaks when you refactor but behaviour has not changed. + +The second is an explicit anti-pattern: **do not write all the tests first, then all the implementation.** Horizontal slicing treats RED as "write every test" and GREEN as "write every implementation", and it produces tests of *imagined* behaviour — tests of the shape of things, insensitive to real change, committed to before the implementation was understood. The correct shape is vertical: one test → one implementation → repeat, each cycle informed by what the last one taught you. + +The workflow is four stages: plan (confirm the interface and which behaviours matter, with the user — you cannot test everything), fire a tracer bullet (one test proving the path works end to end), loop incrementally one behaviour at a time, then refactor once everything is green. Refactoring while RED is forbidden. + +Codebase exploration uses the project's domain glossary, so test names and interface vocabulary match the project's language, and ADRs in the area are respected. + +## Usage + +```text +/tdd +``` + +Describe the feature or bug. Expect the skill to ask what the public interface should look like and which behaviours matter most before any code is written. For diagnosing an existing bug rather than building test-first, use `diagnose`; for throwaway exploratory code, use `prototype`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Philosophy, the horizontal-slicing anti-pattern, the four-stage workflow, and the per-cycle checklist | +| `tests.md` | Skill-root document, cited from Philosophy: worked good and bad test examples | +| `mocking.md` | Skill-root document, cited from Philosophy: mock at system boundaries only, and what not to mock | +| `deep-modules.md` | Skill-root document, cited from stage 1: what a deep module is (small interface, large implementation) and why it is the design to aim for | +| `interface-design.md` | Skill-root document, cited from stage 1: designing interfaces for testability, starting with accepting dependencies rather than creating them | +| `refactoring.md` | Skill-root document, cited from stage 4: the refactor-candidate checklist — duplication, long methods, shallow modules, feature envy, primitive obsession | diff --git a/plugins/bin/skills/tdd/SKILL.md b/plugins/bin/skills/tdd/SKILL.md index 7a98941..ad3be57 100644 --- a/plugins/bin/skills/tdd/SKILL.md +++ b/plugins/bin/skills/tdd/SKILL.md @@ -1,6 +1,9 @@ --- name: tdd -description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +description: > + Use when the user wants a feature built or a bug fixed test-first, in a strict + red-green-refactor loop, one behaviour at a time. Not diagnosing an existing + bug -> `diagnose`. Not throwaway exploratory code -> `prototype`. --- # Test-Driven Development @@ -13,7 +16,7 @@ description: Test-driven development with red-green-refactor loop. Use when user **Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. -See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. +If you need worked examples of the difference — a behaviour-level test beside the implementation-coupled version of the same check — read `tests.md`. If a test needs a collaborator faked, read `mocking.md` before reaching for a mock. ## Anti-Pattern: Horizontal Slices diff --git a/plugins/bin/skills/triage/AGENT-BRIEF.md b/plugins/bin/skills/triage/AGENT-BRIEF.md index 2efecdf..a7e1aa8 100644 --- a/plugins/bin/skills/triage/AGENT-BRIEF.md +++ b/plugins/bin/skills/triage/AGENT-BRIEF.md @@ -1,6 +1,6 @@ # Writing Agent Briefs -An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. +An agent brief is a structured comment posted on an issue in the issue tracker when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. ## Principles @@ -27,7 +27,7 @@ Describe **what** the system should do, not **how** to implement it. The agent w The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. -- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Good:** "Querying the issue tracker for the `needs-triage` label returns issues that have been through initial classification" - **Bad:** "Triage should work correctly" ### Explicit scope boundaries diff --git a/plugins/bin/skills/triage/README.md b/plugins/bin/skills/triage/README.md new file mode 100644 index 0000000..d9f5da2 --- /dev/null +++ b/plugins/bin/skills/triage/README.md @@ -0,0 +1,35 @@ +# triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +## What it does + +Gives issue triage an explicit state model and a fixed set of moves. Every issue carries exactly one **category** role (`bug`, `enhancement`) and one **state** role (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); conflicting state roles are flagged to the maintainer before anything else happens. Unlabeled issues normally enter at `needs-triage`; `needs-info` returns there once the reporter replies. The maintainer can override at any point, and unusual transitions are questioned rather than executed silently. + +A run does one of three things depending on what the maintainer asks for: + +- **Show what needs attention** — three buckets, oldest first: unlabeled, `needs-triage`, and `needs-info` with reporter activity since the last triage notes. +- **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. +- **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. + +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — each is resolved against the tracker's live label set before it is applied, and a name with no counterpart there is reported to the maintainer as a gap rather than guessed at. + +## Composition + +`grill-with-docs` is invoked at step 4 when an issue needs fleshing out; whatever that session establishes is carried into the triage notes so the work is not lost. The reverse direction also exists: `diagnose` names this skill as the place to send a *reported* bug that needs filing rather than debugging. + +## Usage + +```text +/triage +``` + +Then describe what you want in natural language — "show me anything that needs my attention", "let's look at #42", "move #42 to ready-for-agent", "what's ready for agents to pick up?". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The roles and state machine, the three invocation modes, the needs-info template, and how to resume a prior session | +| `AGENT-BRIEF.md` | Skill-root document, cited when an issue moves to `ready-for-agent` (and reused for `ready-for-human`): how to write a brief that stays durable for weeks while the codebase moves under it — describe interfaces and behavioural contracts, not line numbers | +| `OUT-OF-SCOPE.md` | Skill-root document, cited when an enhancement is closed `wontfix` and when checking for prior rejections: how the `.out-of-scope/` knowledge base is laid out and what it is for — institutional memory, and deduplication against re-litigated requests | diff --git a/plugins/bin/skills/triage/SKILL.md b/plugins/bin/skills/triage/SKILL.md index 3dee68f..d83ab5e 100644 --- a/plugins/bin/skills/triage/SKILL.md +++ b/plugins/bin/skills/triage/SKILL.md @@ -1,6 +1,9 @@ --- name: triage -description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +description: > + Use when the user wants an issue created, triaged, or moved through the + tracker's triage states, or an issue prepared for an AFK agent. Not debugging + the bug itself -> `diagnose`. Not fleshing out a design -> `grill-with-docs`. --- # Triage @@ -35,7 +38,7 @@ Five **state** roles: Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. -These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. +These are canonical role names — the actual label strings used in the issue tracker may differ. Resolve each canonical name against the tracker's live label set before applying it, using whichever tracker skill this install provides. If a name has no counterpart there, report the gap and ask the maintainer for the mapping — never substitute a guess. State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. diff --git a/plugins/bin/skills/write-docs/README.md b/plugins/bin/skills/write-docs/README.md new file mode 100644 index 0000000..8ed0156 --- /dev/null +++ b/plugins/bin/skills/write-docs/README.md @@ -0,0 +1,30 @@ +# write-docs + +Produce technical documentation derived from code and spec, one section at a time, with a confirmation gate on every section. + +## What it does + +Casts the agent as a technical writer with one non-negotiable constraint: **every claim must be traceable to a source file line, a spec section, or an explicit user statement.** Nothing is invented, and behaviour that genuinely cannot be documented from the available sources is marked out-of-scope rather than explained away. + +The process is eight steps — identify scope, read and extract, gap check, draft section by section, confirmation gate, delta summary, reader testing, finalise — and several of them are deliberately gated on the human: + +- Files are read only after the user approves them by name. The skill may propose candidates; it waits. +- The **gap check** presents what the code does say and asks the user to fill only what it does not: caller intent, error-handling rationale, non-obvious side effects. +- No section is finalised until the full revised text has been shown. The skill never gates on output the user has not seen, and never reprints the whole document — all edits are surgical. +- **Reader testing** predicts 5–10 questions a target reader would ask, then spawns a sub-agent that receives only the finished doc and the questions — no source files. If the doc cannot answer them, neither can the sub-agent, and the run loops back to drafting. + +Summary and overview sections are written last, once the detail sections are stable. + +## Usage + +```text +/write-docs +``` + +Name the files or modules to document, the target audience (developer / user / contributor / internal), and the documentation type (reference, guide, README section, inline comment, changelog entry). For a PRD, ADR or decision doc, use `grill-me` or `grill-with-docs` instead — those have dedicated handling. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — role, use/do-not-use boundaries, required inputs, constraints, the eight-step process, output format, failure handling, and a nine-item self-check | diff --git a/plugins/bin/skills/write-docs/SKILL.md b/plugins/bin/skills/write-docs/SKILL.md index 0d2da2d..30ea51d 100644 --- a/plugins/bin/skills/write-docs/SKILL.md +++ b/plugins/bin/skills/write-docs/SKILL.md @@ -1,6 +1,11 @@ --- name: write-docs -description: Write documentation for X, document this module, create docs for this feature. Use when the user wants to produce or update technical documentation derived from code, spec, or existing artifacts. Do NOT use when the user wants a PRD, ADR, decision doc, or skill file — those have dedicated skills. +description: > + Use when the user wants technical documentation produced or updated from code + or spec, every claim traced to a source — "write docs for X", "document this + module", "create docs for this feature", "write a README for this". Not an ADR + or other decision record -> `grill-with-docs`. Not an external tool researched + from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec @@ -35,7 +40,8 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `to-prd` or `grill-me` +- User wants an ADR, decision doc, or architecture proposal → `grill-with-docs`, which writes ADRs +- User wants a PRD → no skill in this set produces one; say so rather than redirecting - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -88,7 +94,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `to-prd`, `grill-me`, or `grill-with-docs` +- Requested output is an ADR, decision doc, or architecture proposal → redirect to `grill-with-docs`; for a PRD, say no skill here produces one instead of redirecting ## Self-check diff --git a/plugins/bin/skills/zoom-out/README.md b/plugins/bin/skills/zoom-out/README.md new file mode 100644 index 0000000..ce71a99 --- /dev/null +++ b/plugins/bin/skills/zoom-out/README.md @@ -0,0 +1,25 @@ +# zoom-out + +Ask the agent to go up a layer of abstraction and map the modules and callers around unfamiliar code. + +## What it does + +A single-purpose prompt for the moment you land in a part of the codebase you do not know. Instead of answering at the level of the file in front of it, the agent climbs one layer and produces a map of the relevant modules and their callers — and names them using the project's own domain glossary vocabulary, so the map lines up with the language the rest of the repo already uses. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`, so the router never selects this skill on its own and no other skill can hand off to it. It runs when the human asks for it. That also means its description is written as one plain human-facing sentence — it carries no trigger phrasing or boundary clause, because nothing routes on it. + +## Usage + +```text +/zoom-out +``` + +Best used with the unfamiliar code already in context — the skill widens the view around what you are looking at rather than picking a starting point for you. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — a single instruction, no supporting files | diff --git a/plugins/core/.apm/skills/agentsmd-audit/README.md b/plugins/core/.apm/skills/agentsmd-audit/README.md index d863d94..dec17e6 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/README.md +++ b/plugins/core/.apm/skills/agentsmd-audit/README.md @@ -14,6 +14,9 @@ Runs a single combined pass across every AGENTS.md file in a repo (root and any Provide the path to the repo root to audit when invoking. +Also invoke it proactively after `agentsmd-author` creates or updates an AGENTS.md, or after a +hand-edit made outside `agentsmd-author` — the audit is what confirms the result is safe to commit. + ## Files | File | Purpose | @@ -30,6 +33,6 @@ Provide the path to the repo root to audit when invoking. | `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are -not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `//tests` -when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to -discover (ADR-0017). Run them from a repo checkout, not from an install. +not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips +`//tests` when it generates the flat mirror, because these are dev-time fixtures no +plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md index d8a4a34..e852ebd 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md @@ -1,20 +1,11 @@ --- name: agentsmd-audit description: > - Use when the user wants to review a repo's AGENTS.md file, says "audit this - AGENTS.md", "check my AGENTS.md", "is this AGENTS.md any good", or wants to - know if AGENTS.md is safe to commit — even if they don't use the word - "audit". Also invoke proactively after agentsmd-author creates or updates - AGENTS.md, or after a hand-edit made outside agentsmd-author. Audits a - target repo's AGENTS.md file(s) — root and any nested monorepo files — for - embedded secrets/credentials, structural completeness against the - agents.md common-sections checklist, and drift (referenced commands or - paths that no longer resolve against the repo). Produces a compact - findings report (findings only, no PASS noise) with Why and Fix per - finding. Do not use to audit CLAUDE.md, .cursor/rules, or other - provider-specific adapter files — that's provider-adapter-author's - self-contained concern. Do not use to fix or write AGENTS.md content — use - agentsmd-author instead. + Use when the user wants a repo's AGENTS.md audited for secrets, structure + and drift — "is this AGENTS.md safe to commit" — or after a hand-edit + outside `agentsmd-author`. + Not converting a provider file -> `provider-adapter-author`. + Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: category: docs @@ -23,14 +14,13 @@ metadata: - context7-websites-agents-md - context7-agentsmd-agents-md - governance-secrets-hard-prohibition - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Always run all three checks — this skill does a single combined pass, not staged/gated passes. Don't skip structure or drift checks just because a secrets FAIL was found. -- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. If one exists and duplicates AGENTS.md content, that's `provider-adapter-author`'s concern, not this skill's. -- A missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. - Gather findings internally; don't narrate PASS/FAIL per check as you go — surface them only in the final report. ## Step 1 — Run the validators @@ -41,7 +31,7 @@ bash scripts/validate-structure.sh bash scripts/validate-drift.sh ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL` lines, plus `INFO`/`SUGGESTION` where applicable, with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report @@ -53,16 +43,4 @@ Checked: secrets · structure · drift Then output only findings that were found, in this order within a repo: `### Secrets`, `### Structure`, `### Drift`. Omit a dimension heading entirely if it produced nothing — its absence confirms it passed. Report each finding verbatim as emitted by the scripts (they already carry file:line, Why/Fix or Note). -Close with a result block: - -```text -## Result - -PASS -PASS · P info -PASS (N suggestions) · P info -FAIL (N fails) -FAIL (N fails) · P info -``` - -INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. Omit the suggestion count when there are none, and omit `· P info` when there are none. INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. diff --git a/plugins/core/.apm/skills/agentsmd-author/SKILL.md b/plugins/core/.apm/skills/agentsmd-author/SKILL.md index a38604d..a9b35f3 100644 --- a/plugins/core/.apm/skills/agentsmd-author/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-author/SKILL.md @@ -1,20 +1,10 @@ --- name: agentsmd-author description: > - Use when the user wants to create or update a repo's AGENTS.md file - ("write an AGENTS.md for this repo", "add setup/test instructions for - agents", "update AGENTS.md", "give this package its own AGENTS.md") — even - if they don't name the file explicitly, e.g. "document this for AI coding - tools" or "make sure agents know how to run tests here". Writes/updates - AGENTS.md by exploring the target repo for real build, test, lint, and - style conventions — never invents commands. Supports nested monorepo - placement (a subdirectory can get its own AGENTS.md following - nearest-file-wins precedence). Closes every run by invoking agentsmd-audit - inline, and calls provider-adapter-author when an existing provider file - (CLAUDE.md, etc.) now duplicates what AGENTS.md owns. Do not use to review - an existing AGENTS.md without changing it — use agentsmd-audit instead. Do - not use to convert CLAUDE.md/.cursor/rules into a thin adapter — use - provider-adapter-author instead. + Use when the user wants a repo's AGENTS.md written or updated, root or + nested, including "document this for AI coding tools". Writes only verified + conventions. Not review-only -> `agentsmd-audit`. Not converting CLAUDE.md -> + `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: category: docs @@ -22,16 +12,13 @@ metadata: - agents-md-official - context7-websites-agents-md - context7-agentsmd-agents-md - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Never invent a command. Every line under a setup/test/build section must come from something you actually found in the repo (`package.json` scripts, a `Makefile` target, a CI workflow step, a README). If you can't verify a command, don't include it. -- AGENTS.md has no required schema — don't force every common-sections-checklist heading into every repo. Include only sections that reflect something real about this repo; a thin, accurate file beats a padded, generic one. -- Nested placement is for genuinely different conventions, not convenience. Only create a subdirectory AGENTS.md when that subtree has its own build tool, stack, or conventions distinct from the root — otherwise you're duplicating content the root already covers, which the nearest-file-wins rule doesn't merge back together. -- This skill never touches CLAUDE.md, `.cursor/rules/*.mdc`, `copilot-instructions.md`, or similar provider files directly — that's `provider-adapter-author`'s job. Detect and hand off; don't reconcile it yourself. -- This skill never audits on its own judgment — the closing `agentsmd-audit` invocation is mandatory, not optional, even when the change looks trivial. +- Never write to a provider file yourself, in any circumstance: `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md` and their equivalents are `provider-adapter-author`'s to own. That holds even when the user asks for one in the same breath as AGENTS.md, and even when the file is merely stale or missing a pointer rather than duplicating anything. Detect it and hand off. ## Step 1 — Explore the target repo @@ -40,17 +27,17 @@ Before writing anything, gather real facts: package manager and scripts (`packag ## Step 2 — Decide placement - No `AGENTS.md` at the repo root yet → create one there first, covering whole-repo conventions. -- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Don't repeat root-level content — the nearest-file-wins rule means the nested file is read alone, not merged with the root. +- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Convenience is not a reason to create one — without a distinct stack you are duplicating content the root already covers. **Don't repeat root-level content** in a nested file: the nearest-file-wins rule means it is read alone, never merged back with the root. - Otherwise → update the existing file(s) in place. ## Step 3 — Write or update -Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. +AGENTS.md has no required schema. Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists, because a thin accurate file beats a padded generic one. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile — don't rewrite it yourself. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists, invoke the `provider-adapter-author` skill on it to reconcile — whether it duplicates content the AGENTS.md you just wrote/updated now owns, or is merely stale or missing a pointer to it. Never edit it yourself in either case. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill on the target repo root — its validators take a `` and walk the tree for every AGENTS.md themselves; there is no per-file entry point. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. diff --git a/plugins/core/.apm/skills/provider-adapter-author/README.md b/plugins/core/.apm/skills/provider-adapter-author/README.md index 9dc0652..1282b6c 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/README.md @@ -23,6 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | @@ -30,6 +31,6 @@ Provide the path to the provider-specific file to convert (and the target repo r | `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`) -but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips +but are not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips `//tests` when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 6629822..254196e 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -1,43 +1,41 @@ --- name: provider-adapter-author description: > - Use when the user wants to convert a provider-specific AI instruction file - (CLAUDE.md, .cursor/rules/*.mdc, copilot-instructions.md, etc.) into a - thin adapter that defers to a repo's AGENTS.md — e.g. "reduce duplication - between CLAUDE.md and AGENTS.md", "make CLAUDE.md just import AGENTS.md" - — even if the pattern isn't named explicitly. Also invoke when - agentsmd-author detects an existing provider file overlapping with - AGENTS.md it just wrote. Detects redundant content in a provider file - relative to AGENTS.md and rewrites it down to a minimal reference (an - `@AGENTS.md`-style import where supported, or a text pointer otherwise) - plus genuinely provider-specific additions. Self-validates via a bundled - deterministic script before finishing. Do not use to write or audit - AGENTS.md itself — use agentsmd-author or agentsmd-audit. + Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) + duplicating the repo's AGENTS.md should be cut to a thin adapter — "make + CLAUDE.md just import AGENTS.md". + Not writing the AGENTS file -> `agentsmd-author`. + Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write metadata: category: docs source_keys: - adr-0002-0003-two-tier-claude-md - version: "0.1.0" + version: "0.1.1" --- ## Gotchas -- Not every provider supports cross-file imports. Claude Code does — a `CLAUDE.md` can consist of nothing but one or more `@path` lines (e.g. `@AGENTS.md`), with no other content required. Cursor's `.cursor/rules/*.mdc` and GitHub Copilot's `copilot-instructions.md` have no native import mechanism as of current tooling — for those, "thin" means a short text pointer to AGENTS.md plus only what that tool actually needs, not a literal import line. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for these providers. -- This skill never creates or edits `AGENTS.md` itself. If the target repo has no `AGENTS.md` yet, stop and point the user to `agentsmd-author` first — there's nothing to adapt to. -- Only strip content from the provider file that's genuinely redundant with AGENTS.md. Provider-specific material (IDE settings, tool-only syntax, model-specific instructions) stays — the goal is thin, not empty. -- Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. +- Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. + +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). ## Step 1 — Detect -Look for known provider instruction files in the target repo: `CLAUDE.md` (repo root, and any deployed copies), `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, and similar tool-specific files. Confirm `AGENTS.md` exists at the repo root — if not, stop and tell the user to run `agentsmd-author` first. +Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. + +Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell the user to run `agentsmd-author` first — there is nothing to adapt to. ## Step 2 — Diff and rewrite Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. -- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk. +- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited. + +The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. + +Strip only what is genuinely redundant. Provider-specific material stays even when it is short — the goal is thin, not empty. ## Step 3 — Self-validate @@ -47,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] ``` -Fix any `FAIL` and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md new file mode 100644 index 0000000..e03bbf8 --- /dev/null +++ b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md @@ -0,0 +1,32 @@ +--- +source_keys: + - adr-0002-0003-two-tier-claude-md +--- + +# Known provider instruction files + +Which files to look for when detecting a provider-specific instruction file, whether each provider +resolves a cross-file import, and what a thin adapter therefore looks like for it. + +| Provider | File(s) | Import syntax | Thin adapter shape | Validator flag | +|---|---|---|---|---| +| Claude Code | `CLAUDE.md` at the repo root, plus any deployed copies | Yes — `@path` lines, e.g. `@AGENTS.md` | One or more `@` import lines; no other content is required | none | +| Cursor | `.cursor/rules/*.mdc` | No | A short sentence pointing at `AGENTS.md`, plus the rule's own frontmatter and provider-specific body | `--no-import-syntax` | +| GitHub Copilot | `.github/copilot-instructions.md` | No | A short sentence pointing at `AGENTS.md`, plus Copilot-only instructions | `--no-import-syntax` | +| Anything else | tool-specific instruction file at whatever path the tool documents | Assume no | Text pointer, as above | `--no-import-syntax` | + +A provider not listed here is not evidence it has an import mechanism. Confirm against that tool's +own documentation before emitting an `@`-style line; an unresolved import reads as literal text and +silently drops every rule the adapter was supposed to defer to. + +Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts +independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. No handshake exists to rely on, and no state is +passed in beyond the file paths. diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/sources.md b/plugins/core/.apm/skills/provider-adapter-author/references/sources.md index 7b7071a..654c51d 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/references/sources.md +++ b/plugins/core/.apm/skills/provider-adapter-author/references/sources.md @@ -5,5 +5,5 @@ - **URL:** (in-repo precedent — not an external source or plugin research corpus entry) - **Description:** This repo's own two-tier CLAUDE.md/AGENTS.md pattern: AGENTS.md is the provider-agnostic source of always-on rules; provider-specific files (CLAUDE.md) become thin adapters that import it (`@AGENTS.md` plus provider-specific additions). Grounds this skill's entire adapter-conversion design — the "thin adapter" shape, the `@`-import convention, and the size/duplication expectations enforced by `scripts/validate-adapter.sh`. - **Research doc:** docs/adr/0002-two-tier-claude-md.md, docs/adr/0003-agents-md-provider-agnostic-entry-point.md, providers/claude-code/CLAUDE.md (in-repo ADRs and a live example, not a plugin research corpus entry; referenced here since this skill's design is modeled directly on an existing implementation rather than external research) -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/provider-matrix.md - **Status:** `extracted` diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md b/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md index d137d2b..49c4242 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md @@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg | File | Purpose | |------|---------| -| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | +| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | -Takes ` `, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure. +Takes exactly ` `, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored. + +## What counts as a reference to AGENTS.md + +Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line. + +A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass. + +Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer. + +## Exit codes + +The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these. + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Passes every check | Nothing | +| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file | +| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter | +| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter | diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh index a94d112..9648442 100755 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -14,27 +14,85 @@ Arguments: adapter-file Path to the provider-specific file to check. agents-md-file Path to the AGENTS.md file it should defer to. + Exactly two positional arguments are accepted. Extra ones are rejected + rather than ignored: a third path silently graded nothing but the first + two, so a typo'd invocation passed against the wrong file. + Options: --no-import-syntax The target provider has no native cross-file import - mechanism. Accept a plain-text pointer mention of - "AGENTS.md" instead of requiring an @import-style line. + mechanism. Require a plain-text pointer line naming + "AGENTS.md" instead of an @import-style line; an + @AGENTS.md line alone does not satisfy it, because + such a provider never resolves it. Without this flag + an actual @import line is required, and naming + AGENTS.md in prose alone does not satisfy it. --max-lines N Max non-blank lines allowed in the adapter file before - it's considered no longer "thin". Default: 60. + it's considered no longer "thin". Must be a + non-negative integer. Default: 60. --help, -h Show this help and exit 0. + -- End of options; every later argument is positional. + +Both flags also accept the --flag=value form (--max-lines=40). An unknown +option is reported as an unknown option, not as a missing file. + +What counts as a reference: + + In both modes the named path must be a real path segment ending in + AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must + resolve on disk, relative to the adapter file, to a non-empty file. An + adapter deferring to a path that is not there defers to nothing. + + A mention inside a fenced code block, an indented code block, or an HTML + comment is not credited in either mode. Nothing resolves those, so an + adapter whose only "import" is fenced silently defers to nothing. + + With --no-import-syntax the pointer must read as a pointer: the sentence + naming AGENTS.md has to carry a deference cue (see, read, refer to, + documented in, conventions, ...) and must not be a negation ("do not read + AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer. Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad, missing, or extra argument, an unknown + option, a path that is not a file, or a file that is not UTF-8. Nothing + was graded, so there is no FAIL line and no adapter edit to make: fix + the invocation or the file's encoding and re-run. Kept distinct from 1 + because the skill's own closeout tells the agent to fix every non-zero + exit by editing the provider file, which for a mistyped flag edits the + wrong file forever. + 3 A named input file exists but could not be read (permissions, a + directory swapped in mid-run, I/O error). Also not a FAIL: nothing was + graded and the adapter's contents are unknown, so editing it is + guesswork. Fix the file's readability and re-run. EOF } NO_IMPORT_SYNTAX=0 MAX_LINES=60 ARGS=() +END_OF_OPTS=0 + +require_int() { + # $1 = the value to validate + if [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2 + exit 2 + fi +} while [[ $# -gt 0 ]]; do + if [[ $END_OF_OPTS -eq 1 ]]; then + ARGS+=("$1") + shift + continue + fi case "$1" in + --) + END_OF_OPTS=1 + shift + ;; --help|-h) usage exit 0 @@ -43,10 +101,38 @@ while [[ $# -gt 0 ]]; do NO_IMPORT_SYNTAX=1 shift ;; + --no-import-syntax=*) + echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2 + exit 2 + ;; --max-lines) - MAX_LINES="${2:-}" + if [[ $# -lt 2 ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 2 + fi + MAX_LINES="$2" + require_int "$MAX_LINES" shift 2 ;; + --max-lines=*) + MAX_LINES="${1#--max-lines=}" + if [[ -z "$MAX_LINES" ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 2 + fi + require_int "$MAX_LINES" + shift + ;; + -*) + # Reported as an unknown option rather than falling through to the + # positional bucket, where it used to surface as "'--bogus' is not a + # file" — the right exit code attached to a diagnostic that sends the + # reader looking for a path they never typed. + echo "Error: unknown option '$1'." >&2 + echo "" >&2 + usage >&2 + exit 2 + ;; *) ARGS+=("$1") shift @@ -58,7 +144,14 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 +fi + +if [[ ${#ARGS[@]} -gt 2 ]]; then + echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2 + echo "" >&2 + usage >&2 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -70,17 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5] no_import_syntax = no_import_syntax == "1" max_lines = int(max_lines) +EXIT_FAIL = 1 +EXIT_USAGE = 2 +EXIT_UNREADABLE = 3 + if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(EXIT_USAGE) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(EXIT_USAGE) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, every BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM + is not whitespace in Python, so a CLAUDE.md saved by an editor that emits + one had its first line — the `@AGENTS.md` import, which is the whole + adapter — silently treated as prose. The check then said "no reference to + AGENTS.md" and told the author to add the line already sitting in front of + them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh; + strip it at the reader so no later check has to know about it. + + Every U+FEFF goes, not just one at offset 0. Stripping exactly the first + one left the mirror-image false FAIL for a doubled BOM (two concatenated + files, or a tool that re-adds one) and for a BOM mid-file at the head of + the import line. U+FEFF has no meaning as a character in a markdown + instruction file, so removing all of them cannot lose signal. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and + UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they + decoded clean and produced exactly that false diagnosis anyway. The NUL + byte is the complete signal and is checked first: no plausible markdown + adapter contains one, and every UTF-16/32 encoding of ASCII is full of + them. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + + A file that exists but cannot be read at all is neither a pass nor a FAIL — + nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's + closeout into "fix the FAIL by editing the provider file", which for a file + it cannot open is an instruction to edit blind. + """ + try: + with open(path, "rb") as fh: + raw = fh.read() + except OSError as exc: + print(f"Error: '{path}' exists but could not be read ({exc.strerror}). " + "Nothing was checked — fix whatever is blocking the read " + "(permissions, ownership, the underlying device) and re-run; do " + "not edit the adapter on the strength of this.", file=sys.stderr) + sys.exit(EXIT_UNREADABLE) + if b"\x00" in raw: + print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so " + "it is almost certainly UTF-16 or UTF-32 (with or without a BOM). " + "Re-save it as UTF-8; this check does not guess at other " + "encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + return text.replace("\ufeff", "") + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) +adapter_dir = os.path.dirname(os.path.abspath(adapter_path)) has_fail = False @@ -89,30 +245,229 @@ if not adapter_content.strip(): print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.") print(" Fix: Add at least an import (or text pointer) to AGENTS.md.") print() - sys.exit(1) + sys.exit(EXIT_FAIL) -IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') -lines = adapter_content.splitlines() -import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] + +# --- Inert regions ----------------------------------------------------------- +# +# A reference only counts where something would actually resolve it. Fenced +# code blocks, indented code blocks and HTML comments are shown to the reader +# (or hidden from them) as literal text; Claude Code resolves an @import in +# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact +# copy-the-example-into-the-file mistake this gate exists to catch — exited 0 +# with the adapter deferring to nothing. +# +# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the +# mask: four leading spaces is what opens an indented code block in CommonMark, +# so an import has to sit within three. The mask deliberately does not apply +# that rule to prose pointers, where four-space indentation is ordinary list +# continuation rather than code. +FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$') +COMMENT_RE = re.compile(r'|\Z)', re.DOTALL) + + +def line_offsets(text): + """[(char offset, line without its terminator)] over `text`.""" + out = [] + off = 0 + for raw in text.splitlines(keepends=True): + out.append((off, raw.rstrip("\r\n"))) + off += len(raw) + return out + + +def build_inert_mask(text, offsets): + """Per-character flags: 1 where a reference would never be resolved.""" + mask = bytearray(len(text)) + fence = None # (fence char, opening run length) + for start, line in offsets: + m = FENCE_RE.match(line) + if fence is None: + if m: + fence = (m.group(2)[0], len(m.group(2))) + for i in range(start, start + len(line)): + mask[i] = 1 + continue + for i in range(start, start + len(line)): + mask[i] = 1 + if (m and m.group(2)[0] == fence[0] + and len(m.group(2)) >= fence[1] + and not m.group(3).strip()): + fence = None + for m in COMMENT_RE.finditer(text): + if m.start() < len(mask) and mask[m.start()]: + continue # a literal " +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @AGENTS.md indented up to three spaces is still credited" { + ADAPTER="$TMPDIR/CLAUDE.md" + printf ' @AGENTS.md\n' > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q2: encodings that decode as valid UTF-8 but are not UTF-8 --------------- + +@test "a BOM-less UTF-16LE adapter is an encoding error, not a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-16-le'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "a BOM-less UTF-32LE adapter is an encoding error, not a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-32-le'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "a doubled UTF-8 BOM does not hide the @import line" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write(('@AGENTS.md\n').encode('utf-8'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "a BOM in front of a mid-file @import line does not hide it" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write(('# Claude notes\n\n@AGENTS.md\n').encode('utf-8'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q3: a file that exists but cannot be read is not a FAIL ----------------- + +@test "an adapter that exists but cannot be read exits 3 with a diagnostic and no FAIL" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + chmod 000 "$ADAPTER" + # chmod is not enough under a uid that bypasses it (root in CI containers). + # /proc/self/mem is a regular file whose read returns EIO for every uid, so + # it exercises the same branch where chmod cannot. + if cat "$ADAPTER" >/dev/null 2>&1; then + if [ -e /proc/self/mem ]; then + ADAPTER=/proc/self/mem + else + chmod 644 "$TMPDIR/CLAUDE.md" + skip "no way to make a readable-by-stat, unreadable-by-open file here" + fi + fi + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + chmod 644 "$TMPDIR/CLAUDE.md" + assert_failure 3 + assert_output --partial "could not be read" + refute_output --partial "FAIL" +} + +# --- Q4: the reference has to name, and resolve to, a real AGENTS.md --------- + +@test "an @import naming a path that does not exist is not credited" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@docs/does/not/exist/AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "does not exist" +} + +@test "@NOTAGENTS.md and @zzzAGENTS.md are not imports of AGENTS.md" { + # The decoys are real files, so the on-disk resolution check cannot be what + # rejects them. Only the path-segment boundary can — without the fixtures + # this test passes against a substring match and proves nothing. + cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md" + cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md" + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@NOTAGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + + echo "@zzzAGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @import resolving to a zero-byte AGENTS.md is not credited" { + SUB="$TMPDIR/empty" + mkdir -p "$SUB" + : > "$SUB/AGENTS.md" + ADAPTER="$SUB/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "empty" +} + +@test "an @import naming a real relative path to AGENTS.md is credited" { + mkdir -p "$TMPDIR/docs" + cp "$AGENTS_MD" "$TMPDIR/docs/AGENTS.md" + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@docs/AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q5: --no-import-syntax needs a pointer, not a mention ------------------- + +@test "with --no-import-syntax, a negated mention of AGENTS.md is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +Do NOT read AGENTS.md; it is obsolete. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a past-tense mention of a deleted AGENTS.md is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +We deleted AGENTS.md last year. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer inside a code fence is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +Example of what to write: + +``` +See AGENTS.md at the repo root for shared conventions. +``` +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer inside an HTML comment is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +# Copilot instructions + + +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a name merely ending in AGENTS.md is not a pointer to it" { + # Real decoy files, so the on-disk resolution check cannot be what rejects + # these — only the token boundary in the mention pattern can. zzzAGENTS.md + # is the load-bearing case: dropping the boundary from NOTAGENTS.md leaves + # the fragment "NOT" behind, which the negation cue then rejects for an + # unrelated reason, so that case alone would prove nothing. + cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md" + cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md" + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +See zzzAGENTS.md at the repo root for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + + cat > "$ADAPTER" <<'EOF' +See NOTAGENTS.md at the repo root for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer naming a path that does not exist is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +See docs/does/not/exist/AGENTS.md for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "does not exist" +} + +# --- argument handling ------------------------------------------------------- + +@test "a third positional argument is rejected instead of silently ignored" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" "$TMPDIR/also-not-graded.md" + assert_failure 2 + assert_output --partial "exactly 2 positional arguments" + # The usage text this prints mentions the word FAIL, so refute the shape of + # a real finding line rather than the bare word. + refute_output --partial "FAIL Adapter" +} + +@test "an unknown option is reported as an unknown option, not as a missing file" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" --bogus "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "unknown option '--bogus'" + refute_output --partial "'--bogus' is not a file" + refute_output --partial "FAIL Adapter" +} + +@test "--max-lines=N is accepted in the equals form" { + ADAPTER="$TMPDIR/CLAUDE.md" + { + echo "@AGENTS.md" + for i in $(seq 1 10); do echo "Provider-specific line $i unrelated to AGENTS.md content."; done + } > "$ADAPTER" + run bash "$SCRIPT" --max-lines=5 "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "thin" + + run bash "$SCRIPT" --max-lines=40 "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "with --no-import-syntax, a bare mention with no deference cue is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +# Copilot instructions + +This repo also has an AGENTS.md. + +Prefer inline suggestions over chat for one-line edits. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} diff --git a/plugins/core/.claude-plugin/plugin.json b/plugins/core/.claude-plugin/plugin.json index 167b764..4eb24cf 100644 --- a/plugins/core/.claude-plugin/plugin.json +++ b/plugins/core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.1.1", + "version": "1.1.2", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/.github/plugin/plugin.json b/plugins/core/.github/plugin/plugin.json index 167b764..4eb24cf 100644 --- a/plugins/core/.github/plugin/plugin.json +++ b/plugins/core/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.1.1", + "version": "1.1.2", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/apm.yml b/plugins/core/apm.yml index 140958f..a30c043 100644 --- a/plugins/core/apm.yml +++ b/plugins/core/apm.yml @@ -1,5 +1,5 @@ name: core -version: 1.1.1 +version: 1.1.2 description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. author: name: Defame1297 diff --git a/plugins/core/skills/agentsmd-audit/README.md b/plugins/core/skills/agentsmd-audit/README.md index d863d94..dec17e6 100644 --- a/plugins/core/skills/agentsmd-audit/README.md +++ b/plugins/core/skills/agentsmd-audit/README.md @@ -14,6 +14,9 @@ Runs a single combined pass across every AGENTS.md file in a repo (root and any Provide the path to the repo root to audit when invoking. +Also invoke it proactively after `agentsmd-author` creates or updates an AGENTS.md, or after a +hand-edit made outside `agentsmd-author` — the audit is what confirms the result is safe to commit. + ## Files | File | Purpose | @@ -30,6 +33,6 @@ Provide the path to the repo root to audit when invoking. | `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are -not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `//tests` -when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to -discover (ADR-0017). Run them from a repo checkout, not from an install. +not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips +`//tests` when it generates the flat mirror, because these are dev-time fixtures no +plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/skills/agentsmd-audit/SKILL.md b/plugins/core/skills/agentsmd-audit/SKILL.md index d8a4a34..e852ebd 100644 --- a/plugins/core/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/skills/agentsmd-audit/SKILL.md @@ -1,20 +1,11 @@ --- name: agentsmd-audit description: > - Use when the user wants to review a repo's AGENTS.md file, says "audit this - AGENTS.md", "check my AGENTS.md", "is this AGENTS.md any good", or wants to - know if AGENTS.md is safe to commit — even if they don't use the word - "audit". Also invoke proactively after agentsmd-author creates or updates - AGENTS.md, or after a hand-edit made outside agentsmd-author. Audits a - target repo's AGENTS.md file(s) — root and any nested monorepo files — for - embedded secrets/credentials, structural completeness against the - agents.md common-sections checklist, and drift (referenced commands or - paths that no longer resolve against the repo). Produces a compact - findings report (findings only, no PASS noise) with Why and Fix per - finding. Do not use to audit CLAUDE.md, .cursor/rules, or other - provider-specific adapter files — that's provider-adapter-author's - self-contained concern. Do not use to fix or write AGENTS.md content — use - agentsmd-author instead. + Use when the user wants a repo's AGENTS.md audited for secrets, structure + and drift — "is this AGENTS.md safe to commit" — or after a hand-edit + outside `agentsmd-author`. + Not converting a provider file -> `provider-adapter-author`. + Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: category: docs @@ -23,14 +14,13 @@ metadata: - context7-websites-agents-md - context7-agentsmd-agents-md - governance-secrets-hard-prohibition - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Always run all three checks — this skill does a single combined pass, not staged/gated passes. Don't skip structure or drift checks just because a secrets FAIL was found. -- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. If one exists and duplicates AGENTS.md content, that's `provider-adapter-author`'s concern, not this skill's. -- A missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. - Gather findings internally; don't narrate PASS/FAIL per check as you go — surface them only in the final report. ## Step 1 — Run the validators @@ -41,7 +31,7 @@ bash scripts/validate-structure.sh bash scripts/validate-drift.sh ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL` lines, plus `INFO`/`SUGGESTION` where applicable, with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report @@ -53,16 +43,4 @@ Checked: secrets · structure · drift Then output only findings that were found, in this order within a repo: `### Secrets`, `### Structure`, `### Drift`. Omit a dimension heading entirely if it produced nothing — its absence confirms it passed. Report each finding verbatim as emitted by the scripts (they already carry file:line, Why/Fix or Note). -Close with a result block: - -```text -## Result - -PASS -PASS · P info -PASS (N suggestions) · P info -FAIL (N fails) -FAIL (N fails) · P info -``` - -INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. Omit the suggestion count when there are none, and omit `· P info` when there are none. INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. diff --git a/plugins/core/skills/agentsmd-author/SKILL.md b/plugins/core/skills/agentsmd-author/SKILL.md index a38604d..a9b35f3 100644 --- a/plugins/core/skills/agentsmd-author/SKILL.md +++ b/plugins/core/skills/agentsmd-author/SKILL.md @@ -1,20 +1,10 @@ --- name: agentsmd-author description: > - Use when the user wants to create or update a repo's AGENTS.md file - ("write an AGENTS.md for this repo", "add setup/test instructions for - agents", "update AGENTS.md", "give this package its own AGENTS.md") — even - if they don't name the file explicitly, e.g. "document this for AI coding - tools" or "make sure agents know how to run tests here". Writes/updates - AGENTS.md by exploring the target repo for real build, test, lint, and - style conventions — never invents commands. Supports nested monorepo - placement (a subdirectory can get its own AGENTS.md following - nearest-file-wins precedence). Closes every run by invoking agentsmd-audit - inline, and calls provider-adapter-author when an existing provider file - (CLAUDE.md, etc.) now duplicates what AGENTS.md owns. Do not use to review - an existing AGENTS.md without changing it — use agentsmd-audit instead. Do - not use to convert CLAUDE.md/.cursor/rules into a thin adapter — use - provider-adapter-author instead. + Use when the user wants a repo's AGENTS.md written or updated, root or + nested, including "document this for AI coding tools". Writes only verified + conventions. Not review-only -> `agentsmd-audit`. Not converting CLAUDE.md -> + `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: category: docs @@ -22,16 +12,13 @@ metadata: - agents-md-official - context7-websites-agents-md - context7-agentsmd-agents-md - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Never invent a command. Every line under a setup/test/build section must come from something you actually found in the repo (`package.json` scripts, a `Makefile` target, a CI workflow step, a README). If you can't verify a command, don't include it. -- AGENTS.md has no required schema — don't force every common-sections-checklist heading into every repo. Include only sections that reflect something real about this repo; a thin, accurate file beats a padded, generic one. -- Nested placement is for genuinely different conventions, not convenience. Only create a subdirectory AGENTS.md when that subtree has its own build tool, stack, or conventions distinct from the root — otherwise you're duplicating content the root already covers, which the nearest-file-wins rule doesn't merge back together. -- This skill never touches CLAUDE.md, `.cursor/rules/*.mdc`, `copilot-instructions.md`, or similar provider files directly — that's `provider-adapter-author`'s job. Detect and hand off; don't reconcile it yourself. -- This skill never audits on its own judgment — the closing `agentsmd-audit` invocation is mandatory, not optional, even when the change looks trivial. +- Never write to a provider file yourself, in any circumstance: `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md` and their equivalents are `provider-adapter-author`'s to own. That holds even when the user asks for one in the same breath as AGENTS.md, and even when the file is merely stale or missing a pointer rather than duplicating anything. Detect it and hand off. ## Step 1 — Explore the target repo @@ -40,17 +27,17 @@ Before writing anything, gather real facts: package manager and scripts (`packag ## Step 2 — Decide placement - No `AGENTS.md` at the repo root yet → create one there first, covering whole-repo conventions. -- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Don't repeat root-level content — the nearest-file-wins rule means the nested file is read alone, not merged with the root. +- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Convenience is not a reason to create one — without a distinct stack you are duplicating content the root already covers. **Don't repeat root-level content** in a nested file: the nearest-file-wins rule means it is read alone, never merged back with the root. - Otherwise → update the existing file(s) in place. ## Step 3 — Write or update -Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. +AGENTS.md has no required schema. Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists, because a thin accurate file beats a padded generic one. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile — don't rewrite it yourself. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists, invoke the `provider-adapter-author` skill on it to reconcile — whether it duplicates content the AGENTS.md you just wrote/updated now owns, or is merely stale or missing a pointer to it. Never edit it yourself in either case. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill on the target repo root — its validators take a `` and walk the tree for every AGENTS.md themselves; there is no per-file entry point. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. diff --git a/plugins/core/skills/provider-adapter-author/README.md b/plugins/core/skills/provider-adapter-author/README.md index 9dc0652..1282b6c 100644 --- a/plugins/core/skills/provider-adapter-author/README.md +++ b/plugins/core/skills/provider-adapter-author/README.md @@ -23,6 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | @@ -30,6 +31,6 @@ Provide the path to the provider-specific file to convert (and the target repo r | `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`) -but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips +but are not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips `//tests` when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 6629822..254196e 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -1,43 +1,41 @@ --- name: provider-adapter-author description: > - Use when the user wants to convert a provider-specific AI instruction file - (CLAUDE.md, .cursor/rules/*.mdc, copilot-instructions.md, etc.) into a - thin adapter that defers to a repo's AGENTS.md — e.g. "reduce duplication - between CLAUDE.md and AGENTS.md", "make CLAUDE.md just import AGENTS.md" - — even if the pattern isn't named explicitly. Also invoke when - agentsmd-author detects an existing provider file overlapping with - AGENTS.md it just wrote. Detects redundant content in a provider file - relative to AGENTS.md and rewrites it down to a minimal reference (an - `@AGENTS.md`-style import where supported, or a text pointer otherwise) - plus genuinely provider-specific additions. Self-validates via a bundled - deterministic script before finishing. Do not use to write or audit - AGENTS.md itself — use agentsmd-author or agentsmd-audit. + Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) + duplicating the repo's AGENTS.md should be cut to a thin adapter — "make + CLAUDE.md just import AGENTS.md". + Not writing the AGENTS file -> `agentsmd-author`. + Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write metadata: category: docs source_keys: - adr-0002-0003-two-tier-claude-md - version: "0.1.0" + version: "0.1.1" --- ## Gotchas -- Not every provider supports cross-file imports. Claude Code does — a `CLAUDE.md` can consist of nothing but one or more `@path` lines (e.g. `@AGENTS.md`), with no other content required. Cursor's `.cursor/rules/*.mdc` and GitHub Copilot's `copilot-instructions.md` have no native import mechanism as of current tooling — for those, "thin" means a short text pointer to AGENTS.md plus only what that tool actually needs, not a literal import line. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for these providers. -- This skill never creates or edits `AGENTS.md` itself. If the target repo has no `AGENTS.md` yet, stop and point the user to `agentsmd-author` first — there's nothing to adapt to. -- Only strip content from the provider file that's genuinely redundant with AGENTS.md. Provider-specific material (IDE settings, tool-only syntax, model-specific instructions) stays — the goal is thin, not empty. -- Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. +- Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. + +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). ## Step 1 — Detect -Look for known provider instruction files in the target repo: `CLAUDE.md` (repo root, and any deployed copies), `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, and similar tool-specific files. Confirm `AGENTS.md` exists at the repo root — if not, stop and tell the user to run `agentsmd-author` first. +Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. + +Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell the user to run `agentsmd-author` first — there is nothing to adapt to. ## Step 2 — Diff and rewrite Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. -- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk. +- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited. + +The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. + +Strip only what is genuinely redundant. Provider-specific material stays even when it is short — the goal is thin, not empty. ## Step 3 — Self-validate @@ -47,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] ``` -Fix any `FAIL` and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md new file mode 100644 index 0000000..e03bbf8 --- /dev/null +++ b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md @@ -0,0 +1,32 @@ +--- +source_keys: + - adr-0002-0003-two-tier-claude-md +--- + +# Known provider instruction files + +Which files to look for when detecting a provider-specific instruction file, whether each provider +resolves a cross-file import, and what a thin adapter therefore looks like for it. + +| Provider | File(s) | Import syntax | Thin adapter shape | Validator flag | +|---|---|---|---|---| +| Claude Code | `CLAUDE.md` at the repo root, plus any deployed copies | Yes — `@path` lines, e.g. `@AGENTS.md` | One or more `@` import lines; no other content is required | none | +| Cursor | `.cursor/rules/*.mdc` | No | A short sentence pointing at `AGENTS.md`, plus the rule's own frontmatter and provider-specific body | `--no-import-syntax` | +| GitHub Copilot | `.github/copilot-instructions.md` | No | A short sentence pointing at `AGENTS.md`, plus Copilot-only instructions | `--no-import-syntax` | +| Anything else | tool-specific instruction file at whatever path the tool documents | Assume no | Text pointer, as above | `--no-import-syntax` | + +A provider not listed here is not evidence it has an import mechanism. Confirm against that tool's +own documentation before emitting an `@`-style line; an unresolved import reads as literal text and +silently drops every rule the adapter was supposed to defer to. + +Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts +independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. No handshake exists to rely on, and no state is +passed in beyond the file paths. diff --git a/plugins/core/skills/provider-adapter-author/references/sources.md b/plugins/core/skills/provider-adapter-author/references/sources.md index 7b7071a..654c51d 100644 --- a/plugins/core/skills/provider-adapter-author/references/sources.md +++ b/plugins/core/skills/provider-adapter-author/references/sources.md @@ -5,5 +5,5 @@ - **URL:** (in-repo precedent — not an external source or plugin research corpus entry) - **Description:** This repo's own two-tier CLAUDE.md/AGENTS.md pattern: AGENTS.md is the provider-agnostic source of always-on rules; provider-specific files (CLAUDE.md) become thin adapters that import it (`@AGENTS.md` plus provider-specific additions). Grounds this skill's entire adapter-conversion design — the "thin adapter" shape, the `@`-import convention, and the size/duplication expectations enforced by `scripts/validate-adapter.sh`. - **Research doc:** docs/adr/0002-two-tier-claude-md.md, docs/adr/0003-agents-md-provider-agnostic-entry-point.md, providers/claude-code/CLAUDE.md (in-repo ADRs and a live example, not a plugin research corpus entry; referenced here since this skill's design is modeled directly on an existing implementation rather than external research) -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/provider-matrix.md - **Status:** `extracted` diff --git a/plugins/core/skills/provider-adapter-author/scripts/README.md b/plugins/core/skills/provider-adapter-author/scripts/README.md index d137d2b..49c4242 100644 --- a/plugins/core/skills/provider-adapter-author/scripts/README.md +++ b/plugins/core/skills/provider-adapter-author/scripts/README.md @@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg | File | Purpose | |------|---------| -| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | +| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | -Takes ` `, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure. +Takes exactly ` `, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored. + +## What counts as a reference to AGENTS.md + +Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line. + +A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass. + +Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer. + +## Exit codes + +The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these. + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Passes every check | Nothing | +| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file | +| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter | +| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter | diff --git a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh index a94d112..9648442 100755 --- a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -14,27 +14,85 @@ Arguments: adapter-file Path to the provider-specific file to check. agents-md-file Path to the AGENTS.md file it should defer to. + Exactly two positional arguments are accepted. Extra ones are rejected + rather than ignored: a third path silently graded nothing but the first + two, so a typo'd invocation passed against the wrong file. + Options: --no-import-syntax The target provider has no native cross-file import - mechanism. Accept a plain-text pointer mention of - "AGENTS.md" instead of requiring an @import-style line. + mechanism. Require a plain-text pointer line naming + "AGENTS.md" instead of an @import-style line; an + @AGENTS.md line alone does not satisfy it, because + such a provider never resolves it. Without this flag + an actual @import line is required, and naming + AGENTS.md in prose alone does not satisfy it. --max-lines N Max non-blank lines allowed in the adapter file before - it's considered no longer "thin". Default: 60. + it's considered no longer "thin". Must be a + non-negative integer. Default: 60. --help, -h Show this help and exit 0. + -- End of options; every later argument is positional. + +Both flags also accept the --flag=value form (--max-lines=40). An unknown +option is reported as an unknown option, not as a missing file. + +What counts as a reference: + + In both modes the named path must be a real path segment ending in + AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must + resolve on disk, relative to the adapter file, to a non-empty file. An + adapter deferring to a path that is not there defers to nothing. + + A mention inside a fenced code block, an indented code block, or an HTML + comment is not credited in either mode. Nothing resolves those, so an + adapter whose only "import" is fenced silently defers to nothing. + + With --no-import-syntax the pointer must read as a pointer: the sentence + naming AGENTS.md has to carry a deference cue (see, read, refer to, + documented in, conventions, ...) and must not be a negation ("do not read + AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer. Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad, missing, or extra argument, an unknown + option, a path that is not a file, or a file that is not UTF-8. Nothing + was graded, so there is no FAIL line and no adapter edit to make: fix + the invocation or the file's encoding and re-run. Kept distinct from 1 + because the skill's own closeout tells the agent to fix every non-zero + exit by editing the provider file, which for a mistyped flag edits the + wrong file forever. + 3 A named input file exists but could not be read (permissions, a + directory swapped in mid-run, I/O error). Also not a FAIL: nothing was + graded and the adapter's contents are unknown, so editing it is + guesswork. Fix the file's readability and re-run. EOF } NO_IMPORT_SYNTAX=0 MAX_LINES=60 ARGS=() +END_OF_OPTS=0 + +require_int() { + # $1 = the value to validate + if [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2 + exit 2 + fi +} while [[ $# -gt 0 ]]; do + if [[ $END_OF_OPTS -eq 1 ]]; then + ARGS+=("$1") + shift + continue + fi case "$1" in + --) + END_OF_OPTS=1 + shift + ;; --help|-h) usage exit 0 @@ -43,10 +101,38 @@ while [[ $# -gt 0 ]]; do NO_IMPORT_SYNTAX=1 shift ;; + --no-import-syntax=*) + echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2 + exit 2 + ;; --max-lines) - MAX_LINES="${2:-}" + if [[ $# -lt 2 ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 2 + fi + MAX_LINES="$2" + require_int "$MAX_LINES" shift 2 ;; + --max-lines=*) + MAX_LINES="${1#--max-lines=}" + if [[ -z "$MAX_LINES" ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 2 + fi + require_int "$MAX_LINES" + shift + ;; + -*) + # Reported as an unknown option rather than falling through to the + # positional bucket, where it used to surface as "'--bogus' is not a + # file" — the right exit code attached to a diagnostic that sends the + # reader looking for a path they never typed. + echo "Error: unknown option '$1'." >&2 + echo "" >&2 + usage >&2 + exit 2 + ;; *) ARGS+=("$1") shift @@ -58,7 +144,14 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 +fi + +if [[ ${#ARGS[@]} -gt 2 ]]; then + echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2 + echo "" >&2 + usage >&2 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -70,17 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5] no_import_syntax = no_import_syntax == "1" max_lines = int(max_lines) +EXIT_FAIL = 1 +EXIT_USAGE = 2 +EXIT_UNREADABLE = 3 + if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(EXIT_USAGE) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(EXIT_USAGE) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, every BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM + is not whitespace in Python, so a CLAUDE.md saved by an editor that emits + one had its first line — the `@AGENTS.md` import, which is the whole + adapter — silently treated as prose. The check then said "no reference to + AGENTS.md" and told the author to add the line already sitting in front of + them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh; + strip it at the reader so no later check has to know about it. + + Every U+FEFF goes, not just one at offset 0. Stripping exactly the first + one left the mirror-image false FAIL for a doubled BOM (two concatenated + files, or a tool that re-adds one) and for a BOM mid-file at the head of + the import line. U+FEFF has no meaning as a character in a markdown + instruction file, so removing all of them cannot lose signal. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and + UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they + decoded clean and produced exactly that false diagnosis anyway. The NUL + byte is the complete signal and is checked first: no plausible markdown + adapter contains one, and every UTF-16/32 encoding of ASCII is full of + them. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + + A file that exists but cannot be read at all is neither a pass nor a FAIL — + nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's + closeout into "fix the FAIL by editing the provider file", which for a file + it cannot open is an instruction to edit blind. + """ + try: + with open(path, "rb") as fh: + raw = fh.read() + except OSError as exc: + print(f"Error: '{path}' exists but could not be read ({exc.strerror}). " + "Nothing was checked — fix whatever is blocking the read " + "(permissions, ownership, the underlying device) and re-run; do " + "not edit the adapter on the strength of this.", file=sys.stderr) + sys.exit(EXIT_UNREADABLE) + if b"\x00" in raw: + print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so " + "it is almost certainly UTF-16 or UTF-32 (with or without a BOM). " + "Re-save it as UTF-8; this check does not guess at other " + "encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + return text.replace("\ufeff", "") + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) +adapter_dir = os.path.dirname(os.path.abspath(adapter_path)) has_fail = False @@ -89,30 +245,229 @@ if not adapter_content.strip(): print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.") print(" Fix: Add at least an import (or text pointer) to AGENTS.md.") print() - sys.exit(1) + sys.exit(EXIT_FAIL) -IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') -lines = adapter_content.splitlines() -import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] + +# --- Inert regions ----------------------------------------------------------- +# +# A reference only counts where something would actually resolve it. Fenced +# code blocks, indented code blocks and HTML comments are shown to the reader +# (or hidden from them) as literal text; Claude Code resolves an @import in +# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact +# copy-the-example-into-the-file mistake this gate exists to catch — exited 0 +# with the adapter deferring to nothing. +# +# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the +# mask: four leading spaces is what opens an indented code block in CommonMark, +# so an import has to sit within three. The mask deliberately does not apply +# that rule to prose pointers, where four-space indentation is ordinary list +# continuation rather than code. +FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$') +COMMENT_RE = re.compile(r'|\Z)', re.DOTALL) + + +def line_offsets(text): + """[(char offset, line without its terminator)] over `text`.""" + out = [] + off = 0 + for raw in text.splitlines(keepends=True): + out.append((off, raw.rstrip("\r\n"))) + off += len(raw) + return out + + +def build_inert_mask(text, offsets): + """Per-character flags: 1 where a reference would never be resolved.""" + mask = bytearray(len(text)) + fence = None # (fence char, opening run length) + for start, line in offsets: + m = FENCE_RE.match(line) + if fence is None: + if m: + fence = (m.group(2)[0], len(m.group(2))) + for i in range(start, start + len(line)): + mask[i] = 1 + continue + for i in range(start, start + len(line)): + mask[i] = 1 + if (m and m.group(2)[0] == fence[0] + and len(m.group(2)) >= fence[1] + and not m.group(3).strip()): + fence = None + for m in COMMENT_RE.finditer(text): + if m.start() < len(mask) and mask[m.start()]: + continue # a literal "` is not valid YAML, and `validate.sh` FAILs a frontmatter block that still contains one. -## Auditing guidance +## Where the criteria live -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 +Every FAIL and SUGGESTION criterion for these dimensions is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the body, delegation or comment-discipline dimension in play. 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 d3863af..eac9c27 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md @@ -82,49 +82,8 @@ description: > dispatched and safety-gated. Not conversational git help -> git-workflow. ``` -## Auditing guidance +## Where the criteria live -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. +Every FAIL and SUGGESTION criterion for this dimension is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the description dimension in play. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md b/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md index 9f0a083..85b27bd 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md @@ -38,9 +38,9 @@ whose vocabulary differs per harness — Claude Code names its own tools, Copilo other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent -there, never that the wrong capability is granted. Claude Code honours it for plugin subagents — -`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents -silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. +there, never that the wrong capability is granted. Claude Code honours it for plugin subagents: its +plugin agent-definition reference names the fields plugin agents silently ignore (`hooks`, +`mcpServers`, `permissionMode`), and `disallowedTools` is not among them. `disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md new file mode 100644 index 0000000..5cd8509 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md @@ -0,0 +1,98 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +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, so 400 is the only hard limit + there is — 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`. +- **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. +- **`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 of + `references/description-quality.md`. + +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. + +**An unresolved boundary target is not graded here.** `validate.sh` resolves boundary targets for +agent files at both scopes and tiers the verdict itself — route notation (`/name`, an arrow form) +is an ERROR, the bare prose form a SUGGESTION unless a second target in the same sentence resolves. +Step 1 has already filed it under `### Structure` at that tier. Take the script's verdict rather +than re-resolving the name by hand, and do not re-grade it under description: a hand-walk over a +different universe can contradict the script, and re-grading puts one target in the report twice. +What is left to judgment 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. + +## body, delegation and comment-discipline — `references/body-and-delegation.md` + +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 + +**Never report an agent body as too long on a word count.** ADR-0020 gates a skill body at +600/900 words and deliberately gates an agent body at nothing, because an agent body *becomes* the +system prompt of a fresh context rather than competing with a live conversation. No number exists +to cite. The one length signal that applies is the Copilot runtime's 30,000-character body limit, +which `validate.sh` already reports as a SUGGESTION. Length is judged through the delegation FAIL +above instead. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md b/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md index 748c4f8..7fcca6f 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, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.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/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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 index d6bc4ac..55e949c 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md @@ -34,8 +34,14 @@ first of these: `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. +when the package has no provenance data. Check the exit code before you believe the silence: + +- **0** — a pass, not a skip you need to investigate. Both silent cases above land here. +- **1** — real findings, on stdout with Why and Fix. +- **2** — the check never ran. A missing, doubled, non-file or wrongly-named argument, an + undecodable `apm.yml`, or an absent `python3`, each with a diagnostic on stderr and no findings + at all. Report the `### Provenance` dimension as unverified and quote the reason. An exit 2 is + never a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. ## Manual fallback diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh index 20052bd..1dcc003 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh @@ -16,15 +16,48 @@ Arguments: Exit codes: 0 All checks passed (or nothing to validate, or not plugin scope) 1 One or more checks failed - 2 Script error (unrecognized file extension — expected .md or .agent.md) + 2 Usage error, or the argument is not an agent file this script can read + +An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a +non-zero exit as findings, so a usage error leaving exit 1 with nothing on +stdout was indistinguishable from a clean-but-failing run. Environment and +argument problems exit 2; only real findings exit 1. + +Exit 2 and the silent exit 0 answer two DIFFERENT questions, and neither may +be spelled with the other's code: + + exit 2 the argument is not something this script can audit at all — it is + missing, doubled, not a file, or not named .md / .agent.md. Decided + before the scope walk-up runs, from the argument alone. + exit 0 the argument IS a readable agent file, and the scope walk-up found + no type:-bearing apm.yml above it before hitting the \$HOME, .git or + filesystem-root boundary. That is a real verdict about a real file — + "this agent is user or project scope, so plugin-scope provenance + does not apply to it" — not a rejected input. + +scripts/check-scope-walkup-sync.sh's fixture 6 pins the second: a real agent +file under a \$HOME with a type-bearing apm.yml ABOVE it must exit 0 with empty +output. Widening exit 2 to cover "the walk-up found no package" would break +that fixture AND would be wrong on its own terms, because new-agent.sh happily +scaffolds exactly that layout. Checks performed: 0 source_keys present in agent pair but sources.md absent 1 FILL IN: placeholders in sources.md 2 source_keys in agent files → slug exists in sources.md - 3 Contributing files listed in sources.md exist on disk (plugin-root relative) + 3 Contributing files listed in sources.md exist on disk (plugin-root + relative). An explicit '(none)' skips silently; a Contributing files block + this parser cannot read is reported as an INFO saying checks 3 and 4 did + not run, never skipped silently. 4 Contributing files back-reference the parent slug in their source_keys 5 Research doc field present and not placeholder + +This script has no counterpart to skill-audit's checks 6, 7 and 8 (Research +doc field / upstream forward / upstream reverse are numbered 6, 7, 8 there and +5 here): an agent at plugin scope is a single file with a plugin-root +sources.md, so there is no references/ tree to walk and no upstream research +source index to cross-check. parse_status() and the sources.md-basename gate +that those checks need exist only in the skill-audit copy. EOF } @@ -33,26 +66,131 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then exit 0 fi +# Usage and environment problems exit 2, findings exit 1. See the usage text +# above for why the two must not share a code, and for why "not plugin scope" +# is neither of them. This is a deliberate divergence from validate.sh, which +# has no 2 tier for content: validate.sh always prints PASS lines, so a usage +# error there is visibly not a findings report. This script prints NOTHING on a +# clean run, so exit 1 plus empty stdout was the only signal a caller got +# either way. if [[ $# -lt 1 ]]; then echo "Error: agent-file is required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi +# Extra positional arguments were silently dropped, so a typo'd flag or a second +# path looked like it had been honoured. +if [[ $# -gt 1 ]]; then + echo "Error: expected exactly one argument, got $#: $*" >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + +# python3 is a HARD dependency. Without this preflight a missing interpreter +# produced 'line NN: python3: command not found' and exit 127 — an exit code no +# caller maps to anything, from a message that names this script's line number +# rather than the missing dependency. +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 provenance checks entirely 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 2 +fi + +# A path that does not exist, or exists but is not a regular file, used to reach +# the Python body, get os.path.dirname()'d into some ancestor directory and then +# either report a silent exit 0 (no package above it) or — worse — audit a +# DIFFERENT agent's package while naming the typo'd path. A typo'd target was +# indistinguishable from a clean agent. vale-wrap.sh hard-errors on a +# nonexistent path for exactly this reason. +# +# This is decided from the argument alone, before any walk-up runs, so it cannot +# collide with the not-plugin-scope exit 0: that verdict is only ever reached by +# a file that got past here. +if [[ ! -e "$1" ]]; then + echo "Error: no such file: $1" >&2 + echo " Why: a nonexistent target would otherwise report a silent pass." >&2 + echo " Fix: pass the path of the agent file to validate." >&2 + exit 2 +fi + +if [[ ! -f "$1" ]]; then + echo "Error: not a regular file: $1" >&2 + echo " Why: this script audits one agent file, not a directory of them, and reporting a directory as a pass hides the wrong-target mistake." >&2 + echo " Fix: pass the agent file itself — .apm/agents/.agent.md — not its parent directory." >&2 + exit 2 +fi + +# The extension check used to live inside the Python body. It stays exit 2 and +# keeps its wording; it moves up here so that every "this argument is not +# auditable" verdict is reached in one place, before the interpreter starts and +# before the scope walk-up can turn a bad argument into a silent exit 0. +case "$1" in + *.agent.md | *.md) ;; + *) + echo "Error: unrecognized extension '$(basename "$1")' — expected .md or .agent.md" >&2 + exit 2 + ;; +esac + python3 -u - "$1" <<'PYTHON' import sys import os import re +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and every finding this script prints contains an em dash. +# Pinning only the reads moved the crash from the read to the write — a +# UnicodeEncodeError inside print_findings(), which loses the whole report +# after all the checks have already run. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) -fname = os.path.basename(agent_file) agent_dir = os.path.dirname(agent_file) -# --- Sanity-check extension (single vendor-neutral .agent.md file at plugin/APM scope) --- -if not (fname.endswith('.agent.md') or fname.endswith('.md')): - print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr) - sys.exit(2) +# --- Input ---------------------------------------------------------------- +# Ported from the skill-audit copy, where the same two problems were already +# fixed. +# +# read_text() pins UTF-8 explicitly instead of inheriting +# locale.getpreferredencoding(), which is ASCII under LC_ALL=C — an ordinary em +# dash in an agent file or in sources.md then aborted the run with a bare +# UnicodeDecodeError traceback, or, at the one call site that wrapped its read +# in `except Exception: return []`, reported the unreadable file as having no +# source_keys and therefore as clean. A file that genuinely is not UTF-8 still +# fails; it just says which file and why. +# +# strip_bom() runs on every read because a leading BOM defeats +# parse_frontmatter()'s `^---` anchor, which silently disabled check 2 on a +# BOM-prefixed agent file: no frontmatter parsed means no source_keys parsed +# means nothing to validate. + + +class EncodingError(Exception): + pass + + +def strip_bom(text): + return text[1:] if text.startswith(u'\ufeff') else text + + +def read_text(path): + """File contents as text, UTF-8 and BOM-free, with a diagnostic instead of a traceback.""" + try: + with open(path, encoding='utf-8') as fh: + return strip_bom(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)) # Matches a top-level `type:` line whose value is exactly one of the four # package content types — identical to validate.sh's APM_TYPE_RE. Group 1's @@ -67,15 +205,31 @@ TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?: # keep walking. Stop at a $HOME boundary, a .git boundary, or the filesystem # root: none of these is plugin/APM scope, so this script has nothing to # check there. +# +# Returning None here means NOT PLUGIN SCOPE, which is a verdict, not an error: +# the caller exits 0 silently, and scripts/check-scope-walkup-sync.sh fixture 6 +# pins that. It is deliberately NOT folded into the exit-2 tier above. def find_plugin_root(start_dir): home = os.path.expanduser('~') current = os.path.abspath(start_dir) while True: apm_yml = os.path.join(current, 'apm.yml') if os.path.isfile(apm_yml): - with open(apm_yml) as f: - if any(TYPE_RE.match(line) for line in f): - return current + # An apm.yml is a manifest this script must be able to READ to + # classify scope at all. Under LC_ALL=C the old bare open() decoded + # as ASCII, so a manifest with an accented author name raised + # UnicodeDecodeError mid-walk and killed the run with a traceback. + # It is an environment problem, not a finding, so it exits 2 rather + # than being swallowed into a silent "no package here". + try: + content = read_text(apm_yml) + except EncodingError as exc: + print( + "Error: %s is %s" % (apm_yml, exc), + file=sys.stderr) + sys.exit(2) + if any(TYPE_RE.match(line) for line in content.splitlines()): + return current # $HOME is a non-plugin-scope boundary — checked before the .git test # below (mirrors validate.sh's detect_scope ordering), so a # dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.) can't shadow @@ -101,7 +255,14 @@ if plugin_root is None: sources_md_path = os.path.join(plugin_root, 'sources.md') # --- Helpers --- -PLACEHOLDER_RE = re.compile(r'(? "references/a.md" + return re.sub(r'\s*\(.*$', '', entry).strip() + + # Inline form: value on the same line, comma-separated, no notes. cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE) + if cf_m: + value = cf_m.group(1).strip() + if value.startswith("(none"): + return [] + return [p for p in (strip_note(x) for x in value.split(",")) + if p] or None + + # Bullet form: heading on its own line, one file per following bullet. + cf_m = re.search(r'^\*\*Contributing files:\*\*\s*$', block, re.MULTILINE) if not cf_m: return None - return cf_m.group(1).strip() + files = [] + for line in block[cf_m.end():].splitlines(): + line = line.strip() + if not line: + if files: + break + continue + if not line.startswith("- "): + break + entry = line[2:].strip() + if entry.startswith("(none"): + return [] + entry = strip_note(entry) + if entry: + files.append(entry) + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== -def parse_research_doc(content, slug): +def parse_research_docs(content, slug): + """Every Research doc value under a given slug H2, in document order. + + The caller uses the first and reports the rest. Returning only the first — + what this did before — meant a second '- **Research doc:**' line in one + entry was silently ignored, so an author who added a doc rather than + replacing one got check 5 run against the old value and no hint that the + new one was never looked at. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL ) m = pattern.search(content) if not m: - return None + return [] block = m.group(1) - rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE) - if not rd_m: - return None - return rd_m.group(1).strip() + return [v.strip() for v in + re.findall(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)] findings = [] has_fail = False +# A finding identical in every field is the same finding, and the same file is +# now reached by more than one check — the agent file is read once for its own +# source_keys and again as a contributing file, so an unreadable one would +# otherwise be reported twice with the same words. Distinct findings about the +# same file still both appear. +def _record(entry): + if entry not in findings: + findings.append(entry) + def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix)) + _record(("FAIL", desc, fpath, why, fix, None)) + +# INFO does not set has_fail and does not change the exit code. It is for a +# check that could not RUN — an unverified entry, not a broken one — and it +# exists so that "did not run" is never spelled the same way as "passed". +def emit_info(desc, fpath, note): + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): - for kind, desc, fpath, why, fix in findings: - print(f"FAIL {desc} — {fpath}") - print(f" Why: {why}") - print(f" Fix: {fix}") - print() + for entry in findings: + kind = entry[0] + desc = entry[1] + fpath = entry[2] + why = entry[3] + fix = entry[4] + note = entry[5] + if kind == "FAIL": + print(f"FAIL {desc} — {fpath}") + print(f" Why: {why}") + print(f" Fix: {fix}") + print() + else: + print(f"INFO {desc} — {fpath}") + print(f" Note: {note}") + print() + +def emit_unreadable(rel, exc): + """Report a file this script cannot decode. Never a silent skip.""" + emit_fail( + f"File is {exc}", + rel, + f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — " + f"cannot be read. This used to be swallowed by a bare 'except Exception: return []', " + f"which reported the unreadable file as having no source_keys and therefore as clean.", + f"Re-save '{rel}' as UTF-8." + ) # --- Collect source_keys from agent pair --- -def get_source_keys_from_file(fpath): +def get_source_keys_from_file(fpath, rel): if not os.path.isfile(fpath): return [] try: - with open(fpath) as f: - content = f.read() - except Exception: + content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) return [] fm, _ = parse_frontmatter(content) return parse_source_keys(fm) # Plugin/APM scope is a single vendor-neutral file — no counterpart to merge. -given_keys = get_source_keys_from_file(agent_file) +rel_given = os.path.relpath(agent_file, plugin_root) +given_keys = get_source_keys_from_file(agent_file, rel_given) all_source_keys = given_keys sources_md_exists = os.path.isfile(sources_md_path) -# Early exit: nothing to validate +# Early exit: nothing to validate. The read above can itself raise a finding — +# an unreadable agent file — so print before leaving; the clean case still +# prints nothing and exits 0. if not all_source_keys and not sources_md_exists: - sys.exit(0) + print_findings() + sys.exit(1 if has_fail else 0) sources_content = None sources_slugs = set() if sources_md_exists: - with open(sources_md_path) as f: - sources_content = f.read() + try: + sources_content = read_text(sources_md_path) + except EncodingError as exc: + emit_unreadable("sources.md", exc) + print_findings() + sys.exit(1) sources_slugs = set(parse_h2_slugs(sources_content)) # --- Check 0: source_keys present but sources.md absent --- if not sources_md_exists and all_source_keys: - rel_given = os.path.relpath(agent_file, plugin_root) emit_fail( "source_keys declared but sources.md is absent", rel_given, @@ -240,11 +531,49 @@ for fpath, keys in [(agent_file, given_keys)]: ) # --- Checks 3, 4, 5: Per-slug checks in sources.md --- -for slug in parse_h2_slugs(sources_content): - # Check 3: Contributing files exist (paths relative to plugin root) - cf_value = parse_contributing_files(sources_content, slug) - if cf_value and not cf_value.startswith("(none"): - cf_files = [p.strip() for p in cf_value.split(",") if p.strip()] + +# Every per-slug parser below — parse_contributing_files, parse_research_docs — +# locates its block with pattern.search(), so a slug written twice resolves to +# the FIRST block every time. Iterating the raw heading list therefore checked +# the first block's fields twice and the second block's never: a duplicated slug +# is half-validated, and looked fully validated. The duplicate is announced and +# the repeat visit dropped. +all_slugs = parse_h2_slugs(sources_content) +unique_slugs = [] +for _slug in all_slugs: + if _slug in unique_slugs: + continue + unique_slugs.append(_slug) + _count = all_slugs.count(_slug) + if _count > 1: + emit_info( + f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked", + f"sources.md (## {_slug})", + f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the " + f"second and later blocks' Contributing files and Research doc are never validated — " + f"checks 3, 4 and 5 did not run for them. " + f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys." + ) + +for slug in unique_slugs: + # Checks 3 and 4: Contributing files exist (paths relative to plugin root), + # and back-reference the slug. `[]` and None are NOT the same answer here. + # `[]` is the author writing "(none)" — there is nothing to check and the + # skip is correct. None is a Contributing-files block this parser cannot + # read, and skipping THAT silently disables both checks on the one entry + # least likely to be right, which is the failure mode + # parse_contributing_files' own docstring warns about. Say so out loud. + cf_files = parse_contributing_files(sources_content, slug) + if cf_files is None: + emit_info( + f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed", + f"sources.md (## {slug})", + f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. " + f"Checks 3 and 4 did not run for this slug, so nothing verified that its contributing files exist or name it back. " + f"Write the value as '- **Contributing files:** ', or as a '**Contributing files:**' heading followed by '- ' bullets — " + f"or record '(none)' if this source contributed no files." + ) + elif cf_files: for cf_rel in cf_files: cf_abs = os.path.join(plugin_root, cf_rel) if not os.path.isfile(cf_abs): @@ -256,8 +585,11 @@ for slug in parse_h2_slugs(sources_content): ) else: # Check 4: Bidirectional — file should list slug in its source_keys - with open(cf_abs) as f: - cf_content = f.read() + try: + cf_content = read_text(cf_abs) + except EncodingError as exc: + emit_unreadable(cf_rel, exc) + continue cf_fm, _ = parse_frontmatter(cf_content) cf_keys = parse_source_keys(cf_fm) if slug not in cf_keys: @@ -269,7 +601,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 5: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; check 5 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( "Research doc field missing", diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index fc0f711..738ab4e 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -69,6 +69,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] @@ -253,9 +271,27 @@ def _collect_package(pkg_dir, names): 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()) + # A directory is a skill only if it HOLDS a SKILL.md. An empty + # leftover — a deleted skill whose directory survived, a scaffolding + # stub, an editor's stray mkdir — is untracked by git, so it exists + # on the machine that made it and nowhere else. Counting it made a + # boundary target resolve locally and dangle in a fresh clone: the + # same install-dependence the deployed-tree rule above exists to + # remove, arriving through a different door. + if os.path.isfile(os.path.join(path, 'SKILL.md')): + 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)): + # The same rule one directory over, which until now had no + # counterpart here at all: the skills branch above tests for a + # SKILL.md, the agents branch took every glob hit on trust. A + # DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not + # tell the two apart, so a leftover of that shape resolved a routing + # target on the machine holding it and dangled everywhere else — + # identical install-dependence, arriving through the one door + # nobody guarded. + if not os.path.isfile(path): + continue base = os.path.basename(path) if base.endswith('.agent.md'): base = base[:-len('.agent.md')] @@ -446,8 +482,17 @@ def known_targets(start_dir): # 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`. +# `Not -> `. The example that motivated it is gone: +# diagnose's process chain "fix -> regression-test", which without the +# gate read as a route to a non-existent `regression-test` skill, was cut +# when issue #99 retrofitted that description. So the gate is currently +# UNEXERCISED — gating and not gating produce the same verdict corpus-wide. +# Keep it anyway. It is a false-positive guard against prose no one has +# written yet, and any new process chain re-arms it. Unexercised is not the +# same as unnecessary, and the branch it guards is still load-bearing: the +# bare-arrow rule is the sole extractor for three real targets in +# kyberforge's audit skills (agent-audit -> agent-author, agent-audit -> +# skill-audit, skill-audit -> skill-author), all written unbackticked. # * 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 @@ -525,6 +570,66 @@ def known_targets(start_dir): # ambiguity to resolve, and an author who wants a route checked unconditionally # has two ways to say so. # +# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise +# above rather than a widening of it. Until the sweeps existed, notation was +# only ever seen as the OBJECT OF A ROUTE VERB (`use +# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in +# between. Every one of these therefore exited 0 in total silence — no ERROR, no +# SUGGESTION, not even the target's name: +# Do not use for Y — /no-such-skill instead. +# Do not use for Y; /no-such-skill handles that. +# Do not use for Y (/no-such-skill covers it). +# Do not use for Y — that is /no-such-skill's job. +# Do not use for Y — defer to /no-such-skill. +# Do not use for Y — /no-such-skill. +# Do not use for Y; -> no-such-skill covers it. +# For W, /no-such-skill is the right entry point. +# The target was never EXTRACTED, so the notation-first rule in _add() had +# nothing to apply itself to and the "always blocks" promise was false for the +# ordinary way an author writes the thing. The SUGGESTION tier made it worse +# than a gap: its printed remedy tells the author to "write it as `/name` or +# `-> name` and it will be checked properly", and taking that advice turned a +# visible SUGGESTION into silence — the gate teaching the one edit that blinds +# it. +# +# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point. +# `/name` is Claude Code's invocation syntax and nothing else — no English +# sentence contains one by accident — so the ADR-0020 amendment and +# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So +# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it +# on BOUNDARY_MARKER made that promise false for the last sentence of +# Do not use for Z — use /real-skill instead. +# For W, /no-such-skill is the right entry point. +# which exited 0 in total silence: the boundary clause is one sentence up, so +# the sweep never looked at the sentence carrying the broken route. Extraction is +# per-sentence by design (corroboration is scoped to one sentence), which is +# exactly what made the gap invisible. +# +# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep. +# Neither form is unambiguous: `-> name` is also how a process chain is written +# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a +# file and a skill are all cited. Ungating either would fire on prose that +# carries no routing intent at all — the false-positive class this whole +# extractor is tuned against. +# +# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`, +# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or +# `https://x/y`, not a route. A sentence's closing `.` is not followed by a +# non-space, so `— /no-such-skill.` still counts. +# +# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at +# all here: it holds after a hyphen, so when the trailing lookahead rejected the +# full segment the engine simply backtracked to a shorter hyphen-terminated +# prefix and reported THAT as a route. Every one of these was a hard blocking +# ERROR naming a skill nobody had written: +# the config lives at /opt-tools/bin/thing. -> 'opt' +# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs' +# the file /no-such-skill.md documents it. -> 'no-such' +# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is +# rejected as the path it is. MARKED_TARGET carries the same guard: it had no +# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of +# the two errors above through the route-verb path rather than the sweep. +# # 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. @@ -535,7 +640,8 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY +# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see +# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse +# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route +# verb or an arrow, and it may match a namespaced or path-adjacent token in +# positions this free-standing sweep must refuse. +# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is +# what made `Do not use for Y; -> no-such-skill covers it.` invisible: +# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the +# check. Dropping that prefix costs the one false positive the bare-arrow bullet +# above names — a process chain ending in a hyphenated word, `Instead, reproduce +# -> minimise -> regression-test.` — and costs it only in a sentence that already +# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same +# chain written `Do not use for X — reproduce -> regression-test.` was already a +# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the +# arrow, not whether prose can. An author who means the chain and not a route +# writes it in its own sentence, where neither pattern looks. +NOTATION_SLASH = re.compile( + r"(?|→)\s*(%s)\b" % NAME_HYPH, re.I) +# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT +# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a +# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, +# `.vale.ini` — was invisible to both patterns below, and the two resulting +# failures were different sizes (issue #110): +# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep +# still extracted the target, so the route was checked, but the gate +# reported "no boundary clause" on a clause that was present and working. +# Three authors in two retrofit waves reworded a correct clause to satisfy +# the regex, one of them stripping the very filename that discriminates the +# skill from its neighbour. +# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only +# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill` +# produced no target, no dangling report and no missing-clause SUGGESTION. +# Silence, not noise — the worse of the two failure modes. +# A dot inside a filename is followed by a non-space; a sentence-ending dot is +# followed by whitespace or by end of string. So the class admits a `.` only +# when the next character is not whitespace, which crosses `AGENTS.md` and +# still stops at a real sentence end. +CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))" +ARROW_BOUNDARY = re.compile( + r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, 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) +BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, 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" @@ -573,9 +720,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) # 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. +# re.I here too, and NOT as a tidy-up: this was the one pattern in the file +# built without it, contradicting the uniformity note on CONT_*/ARROW_* above. +# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is +# where an abbreviation most often lands — matched none of the lookbehinds, so +# the clause split at the abbreviation, the corroborating target was stranded on +# the far side of the cut, and a genuinely dangling target silently demoted from +# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described +# directly above, still live for exactly the capitalised half of the input. SENTENCE_SPLIT = re.compile( u'(? name` reached this function with + strict=True from its two call sites, but `/name` did not, so it fell to + _terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The + target then reached unresolved_targets() unblockable — and, before the + companion fix there, unreported as well. `... use /no-such-skill + afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an + author who wants a route checked unconditionally. + """ if not name: return + notation = _notation(text, start, arrow) + if strict is None and notation: + strict = True out.append((name, _terminal(text, end) if strict is None else strict, - _notation(text, start, arrow))) + notation)) def _scan(text, route_re, cont_re, out): @@ -678,7 +848,19 @@ def _extract_sentence(sentence): for match in ARROW_BOUNDARY.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) + # `/name` wherever it sits, in ANY sentence — not only where a route verb or + # an arrow happens to precede it, and NOT only inside a boundary sentence. + # See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings + # this recovers and for why silence was the failure mode. The sweep takes no + # follower test: _add() reads the notation first and marks it. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) if boundary: + # The arrow and backtick forms are ambiguous in ordinary prose, so they + # stay scoped to a sentence that carries a boundary marker. + for match in NOTATION_ARROW.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) for match in BACKTICK.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1)) return out @@ -697,6 +879,85 @@ def boundary_targets(description): return sorted({name for name, _, _ in _extract(description)}) +def _arrow_targets(description): + """Names extracted from ARROW notation specifically. + + Kept apart from boundary_targets() because the arrow form is the one shape + that ALWAYS names a target: ADR-0020's `Not -> `. A clause + written that way from which nothing could be extracted is a parse failure + that deserves its own message, and telling it apart needs the arrow targets + alone rather than every target in the description. + """ + out = [] + for sentence in SENTENCE_SPLIT.split(description): + for match in ARROW_MARKED.finditer(sentence): + name, _, _ = _first(match) + if name: + out.append(name) + for match in ARROW_BOUNDARY.finditer(sentence): + out.append(match.group(1)) + return out + + +def boundary_clause_status(description): + """'absent', 'unparsed' or 'present' — three outcomes, not two. + + Issue #110's standing request: the gate must distinguish "no boundary + clause" from "boundary clause I could not parse". Reporting the first for + the second sends the author hunting for a problem that is not there, and + three of them reworded a correct clause to satisfy a regex instead. + + 'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was + detected and NO target came out of it. The arrow form always names one, so + zero targets means the name is written in a shape the extractor cannot see + — a single-word bare target (`Not X -> forge`, which has to be written + `` `forge` `` or `/forge`) is the live example, since single-word names are + deliberately not matchable bare. + + A PROSE clause yielding no target is NOT reported: "Do not use for anything + else" is a complete and legitimate boundary clause that names nowhere to go. + """ + if BOUNDARY_ARROW.search(description) and not _arrow_targets(description): + return 'unparsed' + if has_boundary_clause(description): + return 'present' + return 'absent' + + +def multi_target_arrow_clauses(description): + """[(first, second)] for arrow clauses naming more than one target. + + Issue #107: only the FIRST target after an arrow is resolved. The + conjunction continuation (CONT_*) is wired to the prose route verbs and + never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither + resolved nor reported, and then printed "1 of 1 boundary target(s) resolve" + on a clause naming two — a gate under-reporting its own coverage, which is + the one failure mode ADR-0020 says a gate must not have. + + The clause is REJECTED rather than the arrow scan extended. Extending it + would widen the resolver's deliberately conservative false-positive tuning + across every arrow in the corpus; rejecting costs nothing and makes the + one-arrow-per-target convention — already what every retrofitted gitea + skill does in practice — explicit instead of folkloric. The caller emits a + SUGGESTION telling the author to split. + """ + hits = [] + for sentence in SENTENCE_SPLIT.split(description): + matches = (list(ARROW_MARKED.finditer(sentence)) + + list(ARROW_BOUNDARY.finditer(sentence))) + for match in matches: + first, _, _ = _first(match) + if not first: + continue + cont = CONT_ANY.match(sentence, match.end()) + if not cont: + continue + second, _, _ = _first(cont) + if second: + hits.append((first, second)) + return hits + + def unresolved_targets(description, known): """Targets resolving to nothing, split into (blocking, reported). @@ -713,6 +974,17 @@ def unresolved_targets(description, known): 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. + + A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed + whitelist of maybe eighty words, so the follower rule says "this token is + outside a list I keep" and not "this is prose" — and the old `continue` + turned that into invisibility at every tier. The gate then failed OPEN on + its own unfamiliarity: any target followed by a word nobody thought to + enumerate was neither blocked nor mentioned, so the check that did not run + said nothing about not running. The follower rule may withdraw the power to + BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note + above is the argument for it — but it may not withdraw visibility, which is + the same rule the corroboration tier already follows. """ blocking, reported = set(), set() for sentence in SENTENCE_SPLIT.split(description): @@ -721,7 +993,10 @@ def unresolved_targets(description, known): 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: + if key in known: + continue + if not may_dangle: + reported.add(name) continue if notation or (resolved - {key}): blocking.add(name) @@ -801,6 +1076,47 @@ def description_value(fm_text): return re.sub(r'\s+', ' ', value).strip() +def hand_invoked(fm_text): + """True when the frontmatter marks this file as reached only by hand. + + `disable-model-invocation: true` removes a skill from the model-visible + listing entirely — it is not preloaded, and the Skill tool refuses to call + it — so its description is never matched against user intent. ADR-0020 and + skill-author's contract give such a skill ONE plain human-facing sentence: + no trigger list, no boundary clause. No validator knew the field existed + (issue #108), so the boundary-clause SUGGESTION fired on exactly the shape + the contract mandates, and its remedy — "add a boundary clause so the router + knows where NOT to send this skill" — was addressed to a router that cannot + see the skill at all. An author who followed the advice made the file worse. + + Only the ROUTING rules are lifted. The body word budget still applies: the + body is loaded on invocation like any other, and competes with the caller's + live conversation the same way. So does the 400-character description FAIL — + a hand-invoked description is not preloaded, but it is still the one line + the user reads when choosing from the `/` menu, and the ceiling is the + outlier stop rather than the style target. + + A parse failure returns False rather than raising. This is a MODIFIER on + other checks, not a check of its own: the frontmatter's validity is decided, + and failed, by description_value() on the same text, and raising a second + exception here would report one broken file twice with two different + diagnoses. + """ + try: + data = yaml.safe_load(fm_text) + except Exception: + return False + if not isinstance(data, dict): + return False + value = data.get('disable-model-invocation') + if isinstance(value, str): + # PyYAML already resolves the unquoted YAML 1.1 booleans, so this only + # catches a QUOTED "true" — which a host reads as truthy and which no + # gate should treat as opting back in to the routing rules. + return value.strip().lower() in ('true', 'yes', 'on') + return value is True + + # --- 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 @@ -915,7 +1231,15 @@ def missing_reference_pointers(body, skill_dir): end = masked.find('\n', match.end()) if end < 0: end = len(masked) - if REFERENCE_PAST.search(masked[start:end]): + # The pointer's OWN SPAN is excised before the sweep. Run over the + # whole line, the past-tense test matched the very path it was judging, + # so a file exempted itself by its NAME: `references/deprecated-api.md`, + # `references/removed-flags.md` and `references/gone.md` produced no + # ERROR at all, while `references/missing.md` — an identical break — + # errored. The exemption is about what the SENTENCE says about the + # pointer, never about what the pointer is called. + line = masked[start:match.start()] + masked[match.end():end] + if REFERENCE_PAST.search(line): continue if REFERENCE_QUALIFIER.search(masked[start:match.start()]): continue @@ -968,8 +1292,14 @@ def agent_description(fm, local_fname): f"not run — {local_fname}") return None -def check_description_budget(value, local_fname): - """ADR-0020 description gates — identical for every scope.""" +def check_description_budget(value, local_fname, by_hand=False): + """ADR-0020 description gates — identical for every scope. + + `by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent + carrying `disable-model-invocation: true` is absent from the model-visible + listing, so the 250-character SUGGESTION — a routing-quality budget — has + no listing to apply to. The 400-character ceiling is unaffected. + """ if not value: return dlen = len(value) @@ -979,13 +1309,13 @@ def check_description_budget(value, local_fname): 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: + elif dlen > DESC_SUGGEST_CHARS and not by_hand: 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): +def check_boundary(value, fpath, local_fname, by_hand=False): """ADR-0020 boundary clause + resolvable boundary targets. agent-author's SKILL.md states that an agent's boundary targets must @@ -1002,10 +1332,31 @@ def check_boundary(value, fpath, local_fname): # 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): + # + # THREE outcomes, not two: "no boundary clause" and "boundary clause I could + # not parse" are different findings (issue #110). And a hand-invoked agent is + # exempt from the clause altogether (issue #108) — the boundary-target + # resolution below still runs, because a target it DOES name should still + # resolve. + status = boundary_clause_status(value) if not by_hand else 'present' + if status == 'absent': 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}") + elif status == 'unparsed': + suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no " + f"target could be read, so the dangling-target check did not run on it — " + f"the clause is PRESENT and unparsed, not missing. Most often the target " + f"is a single word, which is deliberately not matchable bare: write it as " + f"`name` or /name — {local_fname}") + if not by_hand: + # One arrow, one target: a second name after the same arrow is resolved + # by nothing and reported by nothing (issue #107). + for first, second in multi_target_arrow_clauses(value): + suggest(f"an arrow boundary clause names more than one target ('{first}', then " + f"'{second}') and only the first is resolved — the second is checked by " + f"nothing. Split it into one arrow per target: \"Not X -> {first}. " + f"Not Y -> {second}.\" — {local_fname}") targets = boundary_targets(value) if not targets: return @@ -1240,8 +1591,9 @@ def check_apm_agent_file(fpath, allowlist, stem): else: 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) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body — required, non-empty, no placeholder; same Copilot truncation risk # applies since this file compiles verbatim into a real Copilot file downstream. @@ -1336,8 +1688,9 @@ def check_file(fpath, file_provider): else: 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) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body if not body.strip(): diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats index d14635c..7ae7edf 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats @@ -433,3 +433,477 @@ EOF run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" assert_success } + +# --------------------------------------------------------------------------- +# Checks 3 and 4: None ("could not parse") is NOT [] ("explicitly (none)") +# +# parse_contributing_files returns three distinguishable answers and checks 3 +# and 4 have to honour all three. `[]` is the author writing "(none)" — the +# skip is correct and silent. None is a Contributing files block the parser +# cannot read, and skipping THAT silently disables both checks on the one entry +# least likely to be right, which is the failure mode the parser's own +# docstring warns about. The assertions below are therefore about the INFO +# appearing; a silent exit 0 is exactly the bug. +# --------------------------------------------------------------------------- + +@test "INFO: an unparsable Contributing files block names the slug instead of skipping checks 3 and 4 silently" { + local root="$TMPDIR/package" + make_package "$root" + make_agent_with_source_keys "$root" + cat > "$root/sources.md" < "$root/sources.md" < "$root/sources.md" < "$root/sources.md" < "$root/.apm/agents/my-agent.txt" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.txt" + [ "$status" -eq 2 ] + assert_output --partial "unrecognized extension" +} + +@test "exit 2: a PATH with no python3 names the missing dependency instead of exiting 127" { + local root="$TMPDIR/package" + make_package "$root" + make_clean_agent "$root" + local emptybin="$TMPDIR/emptybin" + mkdir -p "$emptybin" + local bash_bin + bash_bin="$(command -v bash)" + run env -i PATH="$emptybin" HOME="$HOME" "$bash_bin" "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + [ "$status" -eq 2 ] + # The needle is the DIAGNOSTIC, not the bare word: with no preflight, bash's + # own "python3: command not found" would satisfy a bare-word match. + assert_output --partial "python3 is required" +} + +@test "reconciliation: a REAL agent file at non-plugin scope still exits 0 silently, never 2" { + # scripts/check-scope-walkup-sync.sh fixture 6 in miniature. The exit-2 tier + # must not widen to cover "find_plugin_root returned None": the file exists, + # is readable and is correctly named — it is simply user/project scope. + local dir="$TMPDIR/anc" + mkdir -p "$dir" + cat > "$dir/apm.yml" < "$fake_home/.apm/agents/my-agent.agent.md" < "$root/sources.md" < "$root/sources.md" < "$root/.apm/agents/my-agent.agent.md" + make_sources_md "$root" "my-source" "(none)" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_failure + assert_output --partial "not valid UTF-8" + refute_output --partial "Traceback" +} + +@test "FAIL: an undecodable contributing file is reported, not a traceback" { + local root="$TMPDIR/package" + make_package "$root" + make_agent_with_source_keys "$root" + printf '\xff\xfe---\nname: other\n---\n' > "$root/.apm/agents/other.agent.md" + make_sources_md "$root" "my-source" ".apm/agents/other.agent.md" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_failure + assert_output --partial "not valid UTF-8" + refute_output --partial "Traceback" +} + +@test "exit 2: an undecodable apm.yml names the file instead of dying mid walk-up" { + local root="$TMPDIR/package" + make_package "$root" + make_clean_agent "$root" + printf 'name: t\nversion: 0.1.0\ntype: skill\n# \xff\xfe\n' > "$root/apm.yml" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + [ "$status" -eq 2 ] + assert_output --partial "not valid UTF-8" + refute_output --partial "Traceback" +} + +@test "a BOM-prefixed agent file still has its source_keys read (check 2 runs)" { + # A leading BOM defeats parse_frontmatter()'s ^--- anchor, so no frontmatter + # parsed means no source_keys parsed means nothing to validate — check 2 + # went silently missing on exactly the file it was pointed at. + local root="$TMPDIR/package" + make_package "$root" + printf '\xef\xbb\xbf---\nname: my-agent\ndescription: A valid agent description.\nsource_keys:\n - ghost-source\n---\n\nYou are a test agent.\n' \ + > "$root/.apm/agents/my-agent.agent.md" + make_sources_md "$root" "my-source" "(none)" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_failure + assert_output --partial "source_keys slug 'ghost-source' not found in sources.md" +} + +@test "under LC_ALL=C a sources.md carrying an em dash is read, not a UnicodeDecodeError" { + local root="$TMPDIR/package" + make_package "$root" + make_agent_with_source_keys "$root" + cat > "$root/sources.md" < "$root/sources.md" < "$root/sources.md" < "$root/.apm/agents/my-agent.agent.md" + make_sources_md "$root" "my-source" ".apm/agents/my-agent.agent.md" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_failure + local count + count="$(printf '%s\n' "$output" | grep -c "^FAIL File is not valid UTF-8" || true)" + [ "$count" -eq 1 ] +} diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats index b58033e..9839841 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats @@ -943,3 +943,23 @@ EOF refute_output --partial "Traceback" refute_output --partial "FileNotFoundError" } + +# --------------------------------------------------------------------------- +# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8') +# +# read_text() in the shared resolver block pins the READS to UTF-8. That moved +# the LC_ALL=C crash to the WRITE: this script's own message text carries em +# dashes (the ADR-0020 boundary SUGGESTION is one), so the streams' ASCII +# default raised UnicodeEncodeError while PRINTING — after every check had +# already run. Here it also flipped a clean exit 0 into a traceback and exit 1. +# --------------------------------------------------------------------------- + +@test "under LC_ALL=C the report is printed, not lost to a UnicodeEncodeError" { + local root="$TMPDIR/locale-pkg" + make_apm_agent "$root" "locale-agent" + run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/locale-agent.agent.md" + assert_success + assert_output --partial "description has no boundary clause" + refute_output --partial "UnicodeEncodeError" + refute_output --partial "Traceback" +} diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/contract.md b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md index 50f6397..cea9336 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md @@ -61,14 +61,29 @@ 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 +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. 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. +router nowhere — a blocking failure in arrow or `/name` form, and in prose form only a SUGGESTION +nobody is forced to act on, which is the worse outcome because it ships. Verify it before writing +it — do not invent a plausible sibling. + +**Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding +replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports it dangling — nothing in the source lines looks +wrong. Reflow so the whole name sits on one line. The same applies to any backticked skill or +agent name anywhere in a description. 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 diff --git a/plugins/kyberforge/.apm/skills/apm-install/README.md b/plugins/kyberforge/.apm/skills/apm-install/README.md index 5cacb8f..995a182 100644 --- a/plugins/kyberforge/.apm/skills/apm-install/README.md +++ b/plugins/kyberforge/.apm/skills/apm-install/README.md @@ -4,7 +4,7 @@ Installs and configures the `apm` (Agent Package Manager) CLI and the agent runt ## What it does -Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). +Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip/pipx), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). ## Usage diff --git a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md index abb520a..054d161 100644 --- a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md @@ -1,13 +1,10 @@ --- name: apm-install description: > - Use when the user wants to install the apm (Agent Package Manager) CLI - itself, pin or upgrade its version, set up an air-gapped/enterprise mirror - install, or install and manage an agent runtime that apm drives (Copilot - CLI, Codex, Gemini, generic llm) — "install apm", "set up apm", "pin apm to - a version", "apm runtime setup", "which runtime will apm run pick". Do not - use for authoring apm.yml, scaffolding a package/marketplace, compiling, - packing, publishing, or running apm audit — use apm-workflow for those. + Use when installing, pinning, or upgrading the apm (Agent Package Manager) + CLI itself, or installing and managing an agent runtime apm drives. Not + authoring, publishing, auditing, or dependency installation for an apm + package -> `apm-workflow`. metadata: category: apm source_keys: @@ -16,13 +13,12 @@ metadata: ## Gotchas -- apm does not execute agents itself — it only installs and manages the runtimes that do. "Install apm" and "install a runtime apm manages" are two separate steps; don't conflate them or skip the second when the user actually wants a working agent CLI, not just the package manager. -- The air-gapped/enterprise mirror path needs `GITHUB_URL` and `VERSION` set together against a downloaded `install.sh` — it does not work through the piped one-liner form. -- `pip install apm-cli` requires Python 3.10+; the quick-install script has no such prerequisite. Prefer the quick-install script unless the environment is pip-first. -- On a Debian/externally-managed Python environment (PEP 668), `pip install apm-cli` fails immediately with `error: externally-managed-environment`. Fall back to `pipx install apm-cli` — same PyPI package, but pipx creates an isolated venv and correctly exposes the `apm` binary on `PATH`. -- Installing the Copilot CLI runtime through `apm runtime setup copilot` requires Node.js v22+ and npm v10+ already present — apm does not install Node/npm for you. +- apm never executes an agent itself — it only installs and manages the runtimes that do. Installing apm alone leaves the user with a package manager and no working agent CLI, so Step 2 is required whenever the user actually wants one; skip it only when they explicitly want the package manager alone. +- `apm runtime setup copilot` needs Node.js v22+ and npm v10+ already on `PATH`; apm will not install them for you. -## Install apm +## Step 1 — Install the apm CLI + +If `apm --version` already answers and the user is not pinning or upgrading, skip to Step 2. Default: @@ -31,16 +27,17 @@ curl -sSL https://aka.ms/apm-unix | sh ``` Escape hatches — combine as needed: -- Pin a version: append `@vX.Y.Z` to the piped script's arguments, e.g. `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. -- Custom install directory: set `APM_INSTALL_DIR` on the piped script's command, e.g. `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. -- Air-gapped / GitHub Enterprise mirror: download `install.sh` first, then run it with `GITHUB_URL` and `VERSION` set, e.g. `GITHUB_URL=https://github.corp.com VERSION=v1.2.3 sh install.sh`. -- pip (Python 3.10+ environments): `pip install apm-cli`. -- pipx (externally-managed/PEP 668 environments where plain `pip install` fails, e.g. Debian): `pipx install apm-cli`. -- Manual: download the platform archive from the GitHub releases page, extract, place the binary on `PATH`. + +- **Pin a version** — append `@vX.Y.Z` to the piped script's arguments: `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. +- **Custom install directory** — set `APM_INSTALL_DIR` on the piped script's command: `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. +- **Air-gapped mirror / GitHub Enterprise** — an air-gapped host cannot reach `aka.ms` at all, so get `install.sh` onto the box and run it from disk instead of piping. Point it at the mirror with `APM_RELEASE_BASE_URL` and pin `VERSION`: `APM_RELEASE_BASE_URL=https://mirror.corp/apm VERSION=v1.2.3 sh install.sh`; add `APM_RELEASE_METADATA_URL` instead if you leave `VERSION` unset. `GITHUB_URL` is the GitHub Enterprise host, not a release mirror. All four are ordinary environment variables that also work through the pipe — running from disk is a network constraint, not a script one. +- **pip** — `pip install apm-cli` requires Python 3.10+. Not on an externally-managed (PEP 668) Python such as Debian or Ubuntu, where it hard-fails with `error: externally-managed-environment`; use pipx below. The quick-install script has no Python prerequisite, so prefer it unless the environment is pip-first. +- **pipx** — `pipx install apm-cli` on those PEP 668 environments. Same PyPI package, but pipx builds an isolated venv and exposes `apm` on `PATH`. +- **Manual** — download the platform archive from the GitHub releases page, extract, and place the binary on `PATH`. Verify with `apm --version`. -## Install or manage an agent runtime +## Step 2 — Install or manage an agent runtime Default: diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/README.md b/plugins/kyberforge/.apm/skills/apm-workflow/README.md index 1996384..1328bed 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/README.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/README.md @@ -4,7 +4,7 @@ Authors, scaffolds, compiles, and audits apm packages and marketplaces. ## What it does -Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches by requested action to one of five reference files, each self-contained for its concern. +Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches on the resolved flow to one of five reference files; each carries that flow's traps and names a sibling file where one flow genuinely depends on another's detail. ## Before you start @@ -24,10 +24,10 @@ Requires the `apm` binary and (for runtime-driven scripts) an agent runtime alre | File | Purpose | |------|---------| -| `SKILL.md` | Dispatch table and cross-cutting gotchas | -| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, registries | +| `SKILL.md` | Dispatch table and the three gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition, the unchecked `type:` field) | +| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, `includes:`, registries; `type:` and `experimental enable registries` traps | | `references/install.md` | apm install, apm install [PACKAGE_REF], --update, --target agent-skills | -| `references/marketplace.md` | Building/registering a marketplace, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | +| `references/marketplace.md` | Building/registering a marketplace, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | | `references/compile.md` | apm compile / pack / publish / run, claude plugin validate agents/ gotcha | -| `references/audit.md` | apm audit, apm audit --ci, apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | +| `references/audit.md` | apm audit vs apm audit --ci (they check different things), apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | | `references/sources.md` | Provenance chain — research sources that informed this skill | diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md index 15270fe..cd258b4 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md @@ -1,18 +1,9 @@ --- name: apm-workflow description: > - Use when the user wants to author or edit an apm.yml manifest - (dependencies, scripts, compilation, policy, registries), scaffold a new - apm package or marketplace (apm plugin init, apm marketplace init/package - add), install or resolve dependencies declared in apm.yml (apm install, - apm install [PACKAGE_REF]), register a marketplace as a consumer, - compile/pack/publish an apm package for distribution, or validate/audit - apm.yml and installed content (apm audit, apm marketplace check) — even if - the user doesn't say "apm" explicitly, e.g. "set up the package manifest", - "scaffold this as an apm package", "install my apm dependencies", "resolve - apm.yml deps", "build the distributable", "check this passes CI". Do not - use for installing the apm binary itself or setting up an agent runtime — - use apm-install for those. + Use when authoring, installing, or publishing an apm package, its apm.yml and + the dependencies it declares, or an apm marketplace — even when the user does + not say "apm". Not the apm binary or an agent runtime -> `apm-install`. metadata: category: apm source_keys: @@ -21,24 +12,21 @@ metadata: ## Gotchas -- `apm.yml`'s `type:` field (`instructions`, `skill`, `hybrid`, `prompts`) constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later doesn't retroactively validate what's already on disk. -- `includes: auto` publishes the authoritative local layout as-is. Anything narrower needs an explicit repo-path list — don't assume `auto` means "scoped down to what's relevant." Note: `auto` still excludes generic root-level passthrough files (README.md, docs/, sources.md, config files) from the `apm pack` distribution bundle — see `references/compile.md`. -- `apm marketplace add` (registering a marketplace as a *consumer*, pointing at someone else's catalog) and `apm marketplace package add` (registering a package by remote reference — `owner/repo`, host URL, or full URL — into a marketplace you're building) are opposite directions of the same command family — don't conflate them. `package add` does NOT accept local paths; a local package is registered by hand-editing `apm.yml`'s `marketplace.packages[]` directly — see `references/marketplace.md`. -- MCP server secrets (headers, env vars) inside `apm.yml` must use `${VAR}` indirection, never literal values, so they're resolved at install/runtime and never committed to the manifest. -- `apm experimental enable registries` must run before any `registry.*` config takes effect. Declaring a `registries:` block or running `apm config set registry.*` without it silently does nothing — no error, no warning. -- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. +- MCP server secrets in `apm.yml` (headers, env vars) must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed. +- `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect anywhere — configure, install or publish. Without it, declaring one silently does nothing: no error, no warning. +- `apm.yml`'s `type:` selects which primitives are processed and is never checked against what `.apm/` holds, so `apm install` and `apm compile` can exit 0 having shipped none of the ones you expected. Set it to cover every primitive the package ships, and confirm the deployed output, not the exit code. Mechanics: `references/configure.md`. ## Step 1 — Dispatch -| Invocation | Action | Reference | +| Condition | Flow | Reference | |---|---|---| -| `/apm-workflow configure` | Author/edit `apm.yml`; scaffold a new package (`apm plugin init`) | `references/configure.md` | -| `/apm-workflow install` | Resolve/fetch dependencies declared in `apm.yml` (`apm install`, `apm install [PACKAGE_REF]`) | `references/install.md` | -| `/apm-workflow marketplace` | Build a marketplace, register packages into it (local: hand-edit `apm.yml`; remote: `apm marketplace package add`), or register a marketplace as a consumer (`apm marketplace init/check/package add/add`) | `references/marketplace.md` | -| `/apm-workflow compile` | Generate per-target output, bundle, or publish (`apm compile`, `apm pack`, `apm publish`) | `references/compile.md` | -| `/apm-workflow audit` | Validate integrity/policy or wire a CI gate (`apm audit`, `apm audit --ci`) | `references/audit.md` | +| Author or edit `apm.yml`, or scaffold a new package (`apm plugin init`) | configure | `references/configure.md` | +| Resolve or fetch the dependencies `apm.yml` declares (`apm install`, `apm install [PACKAGE_REF]`) | install | `references/install.md` | +| Build a marketplace, register a package into it (local: hand-edit `apm.yml`; remote: `apm marketplace package add`), or register someone else's as a consumer (`apm marketplace init/check/package add/add`) | marketplace | `references/marketplace.md` | +| Generate per-target output, bundle, or publish (`apm compile`, `apm pack`, `apm publish`) | compile | `references/compile.md` | +| Validate integrity/policy or wire a CI gate (`apm audit`, `apm audit --ci`) | audit | `references/audit.md` | -Read only the reference file matching the requested action — each is self-contained for its concern. +Read the reference file matching the resolved flow — it carries that flow's own traps, and names a sibling file wherever one flow genuinely depends on another's detail. ## Step 2 — Execute diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md index 4e2b2c4..48eeda4 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md @@ -53,3 +53,7 @@ jobs: ## Claude Code validation is a separate terminal check For packages compiling to the `claude` target, also run `claude plugin validate [--strict]` against the compiled output as a terminal check. `apm audit`/`apm audit --ci` do not check Claude Code-marketplace-specific schema rules (reserved prefixes, the `agents/` stray-file behavior — see `references/compile.md` — etc.), so they don't substitute for it. + +## Gotchas + +- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md index d8c5b5b..0780f24 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md @@ -40,7 +40,7 @@ Bundles a producer package into a distributable artifact. Default to `--dry-run `includes: auto` does NOT sweep generic root-level passthrough files (README.md, docs/, sources.md, config files, etc.) into the `apm pack` distribution bundle (`build/-`) — only `.apm/` primitives, the compiled `plugin.json`, and the content-aware `.mcp.json` handling above make it into that bundle. This does not affect Claude Code's own plugin loading, which reads a plugin's working directory directly via its `source:` path in `marketplace.json`, not the `apm pack` bundle — but it matters for anyone relying on the packed bundle for distribution via `apm install`. -Run `apm audit` after compile/pack, not before — audit scans deployed/compiled output, not the source `apm.yml` manifest; see `references/audit.md`. +Run `apm audit` after compile/pack, not before — audit scans deployed/compiled output, not the source `apm.yml` manifest. Plain `apm audit` is not the CI-equivalent pass (that is `apm audit --ci`, which checks strictly more); see `references/audit.md`. ## Publish @@ -51,6 +51,8 @@ apm publish --package acme/my-skill Publishes a producer package (root containing `apm.yml`, `.apm/`, and optionally a `registries:` block) to a registry. Always dry-run with `-v` first — publishing is not trivially reversible once a version tag is claimed on a registry. +Publishing to a named registry requires `apm experimental enable registries` to have already run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. + ## Run ```bash diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md index 191104d..808f9c2 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md @@ -25,9 +25,9 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains `.apm/` contents +- `type` — `instructions | skill | hybrid | prompts`; selects how the package is processed at install/compile time. It is a routing selector, not a constraint on what `.apm/` may contain (see Gotchas) - `targets` — which harnesses this package compiles to (plural list form preferred; legacy singular `target: copilot,claude` CSV form still accepted) -- `includes` — `auto` publishes the authoritative local layout as-is, or list explicit repo paths. Note: `auto` does not sweep generic root-level passthrough files (README.md, docs/, sources.md, config files) into the `apm pack` distribution bundle — see `references/compile.md` +- `includes` — `auto` publishes the authoritative local layout as-is; it is not scoped down to what's relevant, so anything narrower needs an explicit repo-path list. Note: `auto` also does not sweep generic root-level passthrough files (README.md, docs/, sources.md, config files) into the `apm pack` distribution bundle — see `references/compile.md` - `dependencies`/`devDependencies` — `apm`/`mcp`/`lsp` entries; `devDependencies` share the same shape but are excluded from the shipped artifact - `scripts` — named commands runnable via `apm run ` - `compilation` — target/strategy/exclude/placement controls for `apm compile`/`apm pack` @@ -35,8 +35,6 @@ version: 1.0.0 - `registries` — named registry endpoints for shorthand dependency resolution - `marketplace` — owner + packages list; see `references/marketplace.md` for the full marketplace workflow -See `docs/research/docs/microsoft-apm/configuration.md` for the complete annotated schema. - ## Bumping a package's own version (repo policy) apm ships no version-bump command, so `version:` in a package's own `apm.yml` is a hand edit. @@ -68,14 +66,14 @@ from a content diff. ## MCP server secrets -`${VAR}` indirection is required for MCP server secrets in `apm.yml` — see SKILL.md Gotchas. +`${VAR}` indirection is required for MCP server secrets (headers, env vars) in `apm.yml`, never literal values — see SKILL.md Gotchas. ## Registries (config-level, not `apm.yml`) Any git repo is a valid package source by default — no registry required. To declare named registries for shorthand dependency resolution: ```bash -apm experimental enable registries # required first — see SKILL.md Gotchas +apm experimental enable registries # required first — see Gotchas apm config set registry.corp-main.url https://artifactory.corp.example.com/apm apm config set registry.corp-main.token eyJ... apm config set registry.corp-main.default true @@ -85,4 +83,6 @@ apm config set registry.corp-main.default true ## Gotchas +- `apm.yml`'s `type:` field validates nothing about `.apm/`. It selects processing: `instructions` compiles to AGENTS.md only, `skill` installs a native skill only, `prompts` emits commands only, `hybrid` does both (see `apm_cli/models/validation.py`, `PackageContentType`). apm checks only that the value parses to one of those four strings; no check anywhere compares it against the primitives actually on disk, and no mismatch diagnostic exists. A package declaring `type: instructions` while shipping `.apm/skills/` therefore raises no error — the mismatch resolves silently, either by omitting that primitive from the install/compile output or, in apm 0.28.0 where `get_effective_type()` routes off the on-disk layout and never reads the declared field, by ignoring the declared value outright. Both directions are silent: `apm install` and `apm compile` can exit 0 having shipped none of the primitives you expected. Set `type:` to cover every primitive the package ships, and confirm the deployed output rather than the exit code. +- `apm experimental enable registries` must run before any `registry.*` config takes effect. Declaring a `registries:` block or running `apm config set registry.*` without it silently does nothing — no error, no warning. - `apm plugin init ` run with a positional name argument, from inside a directory already named ``, creates a wrongly-nested `//` subdirectory — it treats the positional arg as "create a new project directory named X," not "confirm the current directory is X." Fix: omit the positional argument entirely when already cd'd into the target package directory — run `apm plugin init --yes --target claude,copilot` instead. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md index 760c44e..c0b8f25 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md @@ -18,3 +18,5 @@ With no arguments, resolves and installs everything declared under `dependencies `--update` is the escape hatch for a lockfile hash mismatch against upstream — normal `apm install` treats that as drift and won't silently accept it; see `references/audit.md` for the CI-side check (`apm install --frozen`) that fails instead of re-resolving. `--target agent-skills` generates the vendor-neutral output directory instead of a Claude/Copilot-specific one — for IDE-agnostic tool support. + +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md index 096bc99..68fa29f 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md @@ -104,6 +104,7 @@ hook. ## Gotchas +- `apm marketplace add` and `apm marketplace package add` point in opposite directions and are easy to conflate: `add` registers someone else's catalog for you to *consume*; `package add` registers a package by remote reference (`owner/repo`, host URL, or full URL) into a marketplace you are *building*. `package add` does not accept a local path — register a local package by hand-editing `marketplace.packages[]`, as above. - Only `claude` and `codex` marketplace output profiles exist in apm 0.28.0 — confirmed via source (`apm_cli/marketplace/output_profiles.py`: `MARKETPLACE_OUTPUTS = {claude: ..., codex: ...}`, nothing else). There is no native Copilot marketplace output; `apm pack` will not generate one. A repo needing a Copilot-consumable marketplace manifest must maintain it separately by other means. - A package/plugin `name` compiling to the `claude` target must avoid Claude Code's reserved name prefixes/values — `anthropic-*`, `claude-*`, `agent-skills`, `official-claude-plugins` — otherwise the compiled `.claude-plugin/marketplace.json` is rejected by Claude Code's validator. This is a Claude Code platform constraint, independent of how the manifest gets authored. - Removing a package from `marketplace.packages[]` in `apm.yml` and re-packing changes the public/consumed catalog. Confirm with the user in conversation before removing the entry and running `apm pack` — this is a normal `apm.yml` edit (not a distinct apm-orchestrate operation with its own gate), so the confirmation is conversational, the same as any other consequential manifest edit. diff --git a/plugins/kyberforge/.apm/skills/forge/README.md b/plugins/kyberforge/.apm/skills/forge/README.md index b8f4857..d76b745 100644 --- a/plugins/kyberforge/.apm/skills/forge/README.md +++ b/plugins/kyberforge/.apm/skills/forge/README.md @@ -1,12 +1,12 @@ # forge -Guided entry point for building or improving something in kyberforge when the target artifact type isn't decided yet. +Guided entry point for building or improving something in any plugin of this repo when the target artifact type isn't decided yet. ## What it does -Grills the user's intent via `bin:grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. +Grills the user's intent via `grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. -Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and their real terminal check (`apm audit`) is already part of their own flow. +Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and no automatic terminal check either — `apm audit` is a separate `apm-workflow` action, not a closing step of the configure or marketplace flow — so forge verifies those routes by reading the written manifest back against the grilled intent. ## Before you start @@ -22,16 +22,19 @@ Skip forge and call the target skill directly (`/skill-author`, `/agent-author`, ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/sources.md` | Provenance chain — research sources that informed this skill | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — Gotchas, the grill step, the classification dispatch table, and the gates common to every route | +| `references/author-routes.md` | The intent classifies as a skill or an agent/subagent definition — fork-vs-inline judgment and the two-tier verification loop | +| `references/apm-routes.md` | The intent classifies as a plugin or a marketplace entry — always-inline invocation, why these routes get no clean-context recheck, and the manual read-back that stands in for one | +| `references/version-bump.md` | A finished route left the owning package's version unbumped — walk-up rule and the clean-context bump brief | +| `references/sources.md` | Never loaded at runtime — provenance chain for the research sources that informed this skill | ## Routes to | Artifact type | Skill | |---|---| -| Skill | `kyberforge:skill-author` | -| Agent / subagent definition | `kyberforge:agent-author` | -| Plugin | `kyberforge:apm-workflow` (configure) | -| Marketplace entry | `kyberforge:apm-workflow` (marketplace) | +| Skill | `skill-author` | +| Agent / subagent definition | `agent-author` | +| Plugin | `apm-workflow` (configure) | +| Marketplace entry | `apm-workflow` (marketplace) | diff --git a/plugins/kyberforge/.apm/skills/forge/SKILL.md b/plugins/kyberforge/.apm/skills/forge/SKILL.md index b0318e0..860e9db 100644 --- a/plugins/kyberforge/.apm/skills/forge/SKILL.md +++ b/plugins/kyberforge/.apm/skills/forge/SKILL.md @@ -1,16 +1,12 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something - but hasn't yet named which of it (skill, agent, plugin, or marketplace - entry) they need — "I want to add something to kyberforge", "not sure if - this should be a skill or a plugin", "help me figure out what to build", - "I have an idea but don't know where it belongs". Grills the intent first, - classifies the target artifact type, then routes to the matching author - skill. Do not use when the user already names the target artifact type or - skill/agent explicitly (e.g. "run /skill-author on my-skill", "create an - agent for X") — route directly to that author skill instead, bypassing - forge. + Use when the user wants to build or improve something but has not yet named + the artifact type — skill, agent, plugin, or marketplace entry; "not sure if + this should be a skill or a plugin", "I have an idea but don't know where it + belongs". Routes to the matching author skill. Do not use when the type is + already named — invoke `skill-author`, `agent-author` or `apm-workflow` + directly. metadata: category: factory source_keys: @@ -21,62 +17,37 @@ metadata: ## Gotchas -- forge is an optional guided entry point, not a gate — the four existing factory skills (`skill-author`, `skill-audit`, `agent-author`, `agent-audit`) plus `apm-workflow` (for plugin/marketplace-entry artifacts) remain directly invokable and forge does not intercept those calls. `plugin-author` and `marketplace-author` were removed per ADR-0015 once issue #90 landed — `apm-workflow` is their sole successor. -- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite sharing a name — `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. Keep this straight when deciding how to invoke a subagent in Step 3. +- forge is an optional guided entry point, not a gate — `skill-author`, `skill-audit`, `agent-author`, `agent-audit` and `apm-workflow` all stay directly invokable, and forge never intercepts a direct call to one. +- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between the two, `references/apm-routes.md` rules the fork out. ## Step 1 — Grill the intent -Call `bin:grill-with-docs` unless a grill session was already performed and is available in the context. -Grilling may surface that the artifact type assumed at the start is wrong, or that the idea splits into more than one artifact. -This step always runs inline, in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth. +Call `grill-with-docs` unless a grill session has already run and is available in the context. -## Step 2 — Classify the artifact type +`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency, so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone. If it does not resolve, grill inline yourself rather than skipping the step: what problem the artifact solves, who invokes it and how, what it must refuse, and which existing skill or plugin already owns part of the job. Say which path you took. -Match the grilled intent against exactly one row (or more than one, if the intent genuinely spans several): +Grilling regularly overturns the artifact type assumed at the start, or splits one idea into several artifacts, so it runs before classification rather than confirming it. Run it inline in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth. -| Intent | Artifact type | Route to | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------| ---------------------------------| -| A reusable capability or workflow the agent should load inline in the main conversation — triggered automatically by description-matching, not a fresh context, and free to bundle its own `references/`, `scripts/`, or `assets/` | Skill | `kyberforge:skill-author` | -| A recurring task needs its own reusable agent/subagent definition — dedicated system prompt, tools, and description, invokable by name across sessions | Agent / subagent definition | `kyberforge:agent-author` | -| A new distributable unit is needed — no existing plugin is the right home for the skill/agent/hook/MCP server being built, or the bundle needs its own manifest, versioning, and install lifecycle separate from what already exists | Plugin | `kyberforge:apm-workflow` (configure — `apm plugin init`) | -| The plugin itself already exists (or was just created) and only its marketplace-facing metadata needs to change — listing it for the first time, or updating its version/description entry — never the plugin's contents | Marketplace entry | `kyberforge:apm-workflow` (marketplace — `apm marketplace package add`) | +## Step 2 — Classify and dispatch -If the intent is genuinely ambiguous between rows even after grilling, ask the user directly rather than guessing. +Match the grilled intent against exactly one row — or more than one, if the intent genuinely spans several artifacts. -Note: plugin and marketplace-entry artifacts route through `kyberforge:apm-workflow` per ADR-0015 — the former `plugin-author` and `marketplace-author` skills were removed once issue #90 landed. +| Intent | Artifact type | Route to | Read | +|---|---|---|---| +| A reusable capability the agent loads inline in the main conversation, triggered by description-matching, free to bundle its own `references/`, `scripts/` or `assets/` | Skill | `skill-author` | `references/author-routes.md` | +| A recurring task needs its own reusable definition — dedicated system prompt, tools and description, invokable by name across sessions | Agent / subagent | `agent-author` | `references/author-routes.md` | +| A new distributable unit — no existing plugin is the right home for the skill, agent, hook or MCP server being built, or the bundle needs its own manifest, versioning and install lifecycle | Plugin | `apm-workflow` (`apm plugin init`) | `references/apm-routes.md` | +| The plugin already exists and only its marketplace-facing metadata changes — a first listing, or a version/description update, never the plugin's contents | Marketplace entry | `apm-workflow` (`apm marketplace package add`) | `references/apm-routes.md` | -This table classifies what to build, not how to run it — a one-off task that merely needs an isolated vs. context-inheriting run (rather than a new, reusable definition) isn't an artifact at all; there's nothing here to route it to. +The table classifies what to build, not how to run it: a one-off task that merely needs an isolated or context-inheriting run is not an artifact and has no row here. If the intent stays genuinely ambiguous between rows after grilling, ask the user rather than guessing. -## Step 3 — Announce, then route +A real artifact that matches no row — a hook, an MCP server, an AGENTS.md, a research doc — has no route here. Say so, hand the user the skill that does own it, and never bend it into a row to make the table fit. -State the classification and which skill(s) will run before invoking anything. +When the intent spans several rows, chain the routes in dependency order — an artifact that must exist on disk before another skill can target it goes first, so `apm-workflow` scaffolds the plugin directory before `skill-author` scaffolds a skill inside it. -**Invoking the author skill(s).** Default to a fork subagent — it inherits the full grilled-intent conversation, so the author skill doesn't need to be re-briefed. Fall back to an inline invocation (same conversation, no subagent) when either is true: -- **Fork is technically unavailable** — already running inside a fork (a fork cannot spawn another fork), a nesting-depth cap is reached, or the environment doesn't support forking. -- **The routed flow needs live user interaction mid-run** that a backgrounded fork can't surface in real time — clarifying questions, confirmation checkpoints, or a HITL gate (e.g. `apm-workflow`'s publish/release steps, or its conversational confirmation before removing a marketplace entry). Judge this from context: if nothing about the routed flow signals a live checkpoint, prefer the fork subagent. +**Announce, then invoke.** State the classification and which skill(s) will run. Then read the reference file for each classified artifact type — only those — and follow it. -`apm-workflow` routes for plugin/marketplace-entry artifacts always run inline — their flows are short, prompt-heavy, or gated, and get no follow-up audit-recheck step to justify running detached (see below). +## Step 3 — Closing gates, common to every route -**After a skill or agent route finishes.** `skill-author` and `agent-author` already close out with their own inline audit (`skill-author` runs `/skill-audit`, `agent-author` invokes `kyberforge:agent-audit` directly) in the same context as the authoring work — that's unchanged. Once that author skill's run has finished, spin up a separate **clean-context subagent** (fresh, not forked, no inherited context) to independently re-run the same audit skill against the finished artifact. This is a distinct verification layer, not a duplicate: the inline audit shares context with the work it's checking and can share its blind spots, while the clean rerun has no stake in the result. - -If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, any actionable finding on its own — loop: re-invoke the author skill (same fork-vs-inline judgment as the initial invocation) to resolve it, then re-run the clean audit again. Repeat until the clean audit comes back with nothing unresolved. Only then is the route done — the same resolve-before-close discipline `skill-author`/`agent-author` already apply to their own inline audit. - -When the intent spans multiple artifact types (e.g. a new skill inside a new plugin, then registering that plugin via `kyberforge:apm-workflow` marketplace), chain the routes in dependency order — an artifact that must exist on disk before another skill can target it goes first (e.g. `apm-workflow` scaffolds the plugin directory via `apm plugin init` before `skill-author` scaffolds a skill inside it). - -## Step 4 — Bump plugin version (if applicable) - -After the routed skill finishes, check if the artifact was created or updated inside a package by walking up from the artifact's path to the nearest ancestor `apm.yml` that declares a top-level `type:` field (`instructions`/`skill`/`hybrid`/`prompts`). An `apm.yml` with no `type:` field is a marketplace-only manifest (see `plugins/kyberforge/docs/research/docs/microsoft-apm/monorepo-and-repo-shapes.md`) — it does not count as a match; skip it and keep walking up. - -**Skip this step if:** -- No ancestor `apm.yml` with a `type:` field is found (the artifact is standalone or scoped to user agent directories) -- The author skill already bumped the package version (check the skill's audit output or completion message for version bump evidence) - -**If a typed `apm.yml` is found and no version bump was done:** - -Invoke `kyberforge:apm-workflow` as a **clean-context subagent** (fresh, not forked) with this brief: - -> "The package at `` gained a new `` (``). Bump the `version` field in that package's `apm.yml`. Determine whether to bump minor (0.1.0) or patch (0.0.1) based on whether this is a new capability (minor) or a fix/refactor (patch). Do not release or tag — just update `apm.yml` and commit." - -Use a clean-context subagent (not forked) so the version bump decision is made independently without anchoring to the earlier authoring context. This gives apm-workflow a clear, isolated directive. - -Report completion to the user: "Updated `` version from X.Y.Z to X.Y.Z to reflect the new ``." +- **Resolve before closing.** A route is finished only when its verification reports nothing unresolved. An actionable finding reopens the route; it is never reported onward as a caveat. +- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did. diff --git a/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md b/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md new file mode 100644 index 0000000..3e04823 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md @@ -0,0 +1,42 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a plugin or marketplace entry to apm-workflow + +Reached from `SKILL.md` Step 2 when the classified artifact is a plugin or a marketplace entry. +Both route to `apm-workflow` — a plugin to its configure flow (`apm plugin init`), a marketplace +entry to its marketplace flow (`apm marketplace package add`). + +No other skill is a candidate for these two rows: `plugin-author` and `marketplace-author` were +removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole successor. + +## Always inline, never forked + +Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or +gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace +entry takes a conversational confirmation — and a backgrounded fork cannot surface those +checkpoints to the user in real time. + +## No clean-context recheck, and no automatic audit + +Skill and agent routes close with a clean-context audit rerun; these two do not, and the omission +is deliberate rather than an oversight. Neither artifact type has an audit skill counterpart to +re-run, so detaching the route to earn a recheck it would never get buys nothing. + +These routes get no automated terminal check either. `apm audit` is a separate action on +`apm-workflow`'s own dispatch table, not a closing step of the configure or marketplace flow a +forge route lands in, so a completion message from either says nothing about it. Do not wait for +one and do not report one you did not see. + +Verify by hand instead. Read back what the route wrote against what the grill settled: + +- **Plugin** — the package directory exists where the intent said it should, and its `apm.yml` + carries the intended `name`, a top-level `type:` field, and a `version`. +- **Marketplace entry** — the entry names that package, points at the source the intent settled + on, and carries the version the package actually declares. + +If the change warrants the full integrity and policy check rather than a read-back, invoke +`apm-workflow` again for its audit action and run `apm audit` deliberately. Then return to +`SKILL.md` Step 3 for the closing gates common to every route. diff --git a/plugins/kyberforge/.apm/skills/forge/references/author-routes.md b/plugins/kyberforge/.apm/skills/forge/references/author-routes.md new file mode 100644 index 0000000..237debc --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/author-routes.md @@ -0,0 +1,44 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a skill or agent to its author skill + +Reached from `SKILL.md` Step 2 when the classified artifact is a skill or an agent/subagent +definition. Route a skill to `skill-author` and an agent to `agent-author`. The two branches +differ on one axis only — which audit skill verifies the result — and everything below applies to +both. + +## Choose fork or inline + +Default to a **fork subagent**. It inherits the full grilled-intent conversation, so the author +skill does not need re-briefing on what the user asked for or what the grill settled. + +Fall back to an **inline invocation** — same conversation, no subagent — when either holds: + +- **Fork is technically unavailable.** You are already running inside a fork (a fork cannot spawn + another fork), a nesting-depth cap is reached, or the environment does not support forking. +- **The routed flow needs live user interaction mid-run** that a backgrounded fork cannot surface + in real time: clarifying questions, confirmation checkpoints, or a HITL gate. Judge this from + context — if nothing about the flow signals a live checkpoint, prefer the fork. + +## Two-tier verification + +Both author skills already close out with their own inline audit, in the same context as the +authoring work: `skill-author` runs `/skill-audit`, `agent-author` invokes +`agent-audit`. That is tier one, and forge does not change it. + +Tier two belongs to forge. Once the author skill's run has finished, spin up a separate +**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run the +same audit skill against the finished artifact. This is a distinct verification layer, not a +duplicate: the inline audit shares context with the work it is checking and can share its blind +spots, while the clean rerun has no stake in the result. + +If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, +any actionable finding on its own — loop: re-invoke the author skill (same fork-versus-inline +judgment as the first invocation) to resolve it, then re-run the clean audit. Repeat until the +clean audit comes back with nothing unresolved. Only then is the route done. This is the same +resolve-before-close discipline the author skills already apply to their own inline audit. + +Return to `SKILL.md` Step 3 for the closing gates common to every route once the loop closes. diff --git a/plugins/kyberforge/.apm/skills/forge/references/sources.md b/plugins/kyberforge/.apm/skills/forge/references/sources.md index 5d9aac7..4f065b3 100644 --- a/plugins/kyberforge/.apm/skills/forge/references/sources.md +++ b/plugins/kyberforge/.apm/skills/forge/references/sources.md @@ -4,15 +4,15 @@ - **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. Grounds Step 3's fork-vs-inline invocation logic: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. -- **Contributing files:** SKILL.md +- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds the fork-vs-inline invocation logic in `references/author-routes.md`, the always-inline decision for the apm routes in `references/apm-routes.md`, and the clean-context bump subagent in `references/version-bump.md`: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. +- **Contributing files:** SKILL.md, references/author-routes.md, references/apm-routes.md, references/version-bump.md - **Status:** `extracted` ## context7-websites-code-claude - **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 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry warning against conflating the two. +- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry in `SKILL.md` warning against conflating the two; nothing else in this skill draws on it, and no `references/` file mentions the `context: fork` field. - **Contributing files:** SKILL.md - **Status:** `extracted` @@ -28,7 +28,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md -- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category. forge has none of that bulk, so a lean SKILL.md-plus-provenance-file shape is spec-legitimate; the `references/sources.md` in this directory exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. +- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: ADR-0020's rule that dispatch is mandatory at two or more mutually exclusive flows, which forge's four-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. - **Contributing files:** SKILL.md - **Status:** `extracted` diff --git a/plugins/kyberforge/.apm/skills/forge/references/version-bump.md b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md new file mode 100644 index 0000000..5c05039 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md @@ -0,0 +1,40 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Bumping the package version after a route + +Reached from `SKILL.md` Step 3 after a route has finished. A skill route always lands here: +`skill-author` moves only a skill's own `metadata.version`, which is not the package manifest's +number, so the package version is still behind when it reports done. `agent-author` bumps the +resolved package's `apm.yml` itself at plugin/APM scope, and `apm-workflow`'s configure flow +carries the same policy — read those routes' output before acting here, because a second bump for +one change is wrong. + +## Find the owning package + +Walk up from the artifact's path to the nearest ancestor `apm.yml` that declares a top-level +`type:` field (`instructions`, `skill`, `hybrid` or `prompts`). + +An `apm.yml` with **no** `type:` field is a marketplace-only manifest: it lists packages rather +than declaring one, so it does not count as a match. Skip it and keep walking up. + +Skip this step entirely if no ancestor `apm.yml` carries a `type:` field: the artifact is then +standalone or scoped to a user agent directory, and there is no package to version. + +## Delegate the bump + +Invoke `apm-workflow` as a **clean-context subagent** — fresh, not forked — with this +brief: + +> "The package at `` gained a new `` (``). Bump the +> `version` field in that package's `apm.yml`. Determine whether to bump minor (0.1.0) or patch +> (0.0.1) based on whether this is a new capability (minor) or a fix/refactor (patch). Do not +> release or tag — just update `apm.yml` and commit." + +Clean context rather than a fork is the point: the bump decision is made independently, without +anchoring on the authoring conversation that just argued for the artifact's significance. + +Then report to the user: "Updated `` version from `` to `` to reflect the +new ``." diff --git a/plugins/kyberforge/.apm/skills/skill-audit/README.md b/plugins/kyberforge/.apm/skills/skill-audit/README.md index d46ca78..277c836 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/README.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/README.md @@ -6,7 +6,7 @@ Audit a skill directory against the agentskills.io specification and the house c 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 five dimension groups, loading one rubric from `references/` per group +3. Applies qualitative checks across five dimension groups — always loading `references/finding-criteria.md`, then one rubric from `references/` per group the criteria put in play 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). @@ -35,7 +35,8 @@ Provide the path to the skill directory to audit when invoking. | `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` | 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/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file loaded on every run; it decides which rubrics below are worth loading | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked (`disable-model-invocation`) contract, the three-part shape, when an indirect trigger is warranted, near-miss exclusions, and a before/after pair | | `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 | diff --git a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md index 5cc3a82..1a66612 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md @@ -30,14 +30,14 @@ Resolve all three paths against this skill's own directory so they work from a r ```bash bash scripts/validate.sh bash scripts/validate-provenance.sh -scripts/vale-wrap.sh /SKILL.md +bash scripts/vale-wrap.sh /SKILL.md ``` -`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both. +`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation. 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. -`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. +`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a usage or environment error, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason. Never grade an exit 2 as a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. `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: @@ -53,9 +53,9 @@ Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/ ## Step 3 — Qualitative audit -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). +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body-discipline | `references/body-discipline.md` | @@ -63,7 +63,7 @@ Load a dimension's rubric before judging that dimension. Each is self-contained, | file-structure, internal-consistency | `references/file-structure.md` | | formatting, scripts | `references/formatting-and-scripts.md` | -Cite file and line number for every finding. +Each rubric is self-contained and grounded in the agentskills.io specification plus the house context budget (ADR-0020). Cite file and line number for every finding. ## Step 4 — Report 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 4f4af51..31f28c6 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -31,7 +31,16 @@ Include content the agent lacks: 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: +from the ADR-0020 gate because no such file exists on disk. + +**A dispatch table satisfies this requirement on its own.** A table row already pairs a condition +with a target, which is exactly what the literal form encodes; restating each row underneath as a +prose conditional duplicates the routing in the one body whose whole purpose is to be short. Where a +body dispatches, audit the table for condition/target completeness and stop there — do not require +the conditional form as well. The literal form is what a body needs when it loads a reference +*without* a dispatch table: a single mid-procedure deepening, an escape hatch, an error path. + +Move: - Lookup tables and spec restatements - Output schemas, templates and example blocks @@ -71,10 +80,28 @@ table** plus the gates common to every branch, and each flow lives in its own se `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. +The reference shape in this repo is `apm-workflow`: a **294-word body** dispatching to 3,154 words +of references across five mutually exclusive flows. Its whole-file count is 348 words — cite 294 +when calibrating a body, or the conflation this section warns against reappears in the finding +itself. The 3,154 counts the five flow files only; `references/sources.md` is a provenance record +and is never loaded at runtime, so counting it inflates the dispatched total. + +### What earns the wiring exemption + +A dispatch table earns the exemption above on its properties, not on which skill it appears in. +Audit any dispatching body against these four: + +- Every flow the skill handles has a row, and every row names a target file that exists on disk. +- Each row pairs a condition the agent can evaluate from the request with exactly one target. A row + keyed on a literal slash invocation fails this: a model-invoked activation never produces that + string, so the routing silently falls to whatever else the row carries. +- One line after the table tells the agent to read the file its row matched, and only that one. +- The gates every branch needs sit in the body, not inside one flow's file — see the reachability + precondition below. + +A table missing any of the four is not exempt, and the literal-conditional requirement applies to it +as written. The exemption covers the wiring form only: every other rule in this file applies to a +dispatching skill exactly as it applies to any other. ## Gotchas sections @@ -96,19 +123,32 @@ Constraints: `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. + coming. This one is the auditor's call — no script detects it. The Fix is conditional: delete the + Gotcha only if the surviving copy is reachable from every branch that needs it — see the + reachability precondition below. - **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: +Worked negative example — **`git-commits` v0.1.2 at commit `5e23250`, a fixed pre-retrofit +snapshot, not the current file.** The live skill is v0.1.3 and matches none of the citations below; +they are quoted as they stood before the ADR-0020 retrofit, and are not to be refreshed against +`HEAD`. The snapshot is reachable only from a checkout of the authoring repo — an installed plugin +cache holds no git history and no such path — so read the citations below as quoted rather than +going to look for the file. From a checkout: + +```text +git show 5e23250:/.apm/skills/git-commits/SKILL.md +``` + +That body carried twelve Gotchas, four of which restated content already below them or already in +the description: | Gotcha | Restates | |---|---| -| `:31` "Communicates SemVer impact" | the description | +| `:31` "SemVer mapping is not optional" | 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` | @@ -118,6 +158,25 @@ All four are FAILs under the paraphrase rule. The entry count and the section's 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. +### The paraphrase rule has a reachability precondition + +**A Gotcha that restates a step may be deleted only when the surviving copy is reachable from every +branch that needs it.** In a dispatch body it usually is not: each flow file is loaded alone, so a +step in one is invisible to an invocation that took another branch. When the restated rule is a +safety gate more than one flow needs, the Fix is to **move it into the body's common-gates section**, +never to drop it in favour of the per-flow copy. + +Row four is the case that proves it. Following the rule literally, the retrofit deleted the +always-loaded secrets Gotcha and kept step 2 of `references/create-commit.md` — but `git-commits` +dispatches to exactly one flow file, and `references/rewrite-history.md` stages changes and runs +`--amend`, which commits newly staged content exactly as a fresh commit does. A grep for `secret` +across the skill in that state returned one hit, on a path two of three branches never reach: that +branch could commit a credential with no check anywhere in its loaded context, against this repo's +governance hard prohibition. v0.1.3 carries the rule as gate 2 of "Gates on every flow" instead. + +So check reachability before writing the Fix. Rows one to three are unaffected — the description is +loaded on every invocation, and confirmation is likewise a common gate rather than a per-flow step. + ## Calibrating control **Be prescriptive** when operations are fragile, consistency matters, or a specific sequence must be @@ -146,23 +205,5 @@ Use pypdf, pdfplumber, PyMuPDF, or pdf2image... Use pdfplumber for text extraction. For scanned PDFs requiring OCR, use pdf2image instead. ``` -## Auditing guidance - -Flag as FAIL if: - -- 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: - -- 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 -- Content that only one branch reaches is inlined where a `references/` file would serve +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. 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 411d178..ef027f8 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md @@ -40,8 +40,11 @@ A model-invoked description carries exactly three things: 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. + a real skill directory or agent file in the authoring source. `validate.sh` checks that + deterministically and grades it by notation: an unresolved `/name` or arrow target is an ERROR + and reaches the report as a Structure FAIL, while an unresolved prose-form target ("use `y` + instead") is only a SUGGESTION unless a second target in the same sentence resolves. Take the + script's tier as given and report it once, under Structure. Everything else belongs in the body or in `README.md`. @@ -80,38 +83,5 @@ description: > (`data-model` is illustrative. In a real description the target has to resolve.) -## 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. -- **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: - -- **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. +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md index 805ba14..33c3531 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md @@ -24,9 +24,19 @@ knows to look at. Flag any other directory as a FAIL. ## 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/`. +resolving there. Flag a path in `SKILL.md`, `scripts/`, `references/` or `assets/` when it +**resolves outside the skill directory** — an absolute repo path +(`plugins//skills//` and its APM-native equivalent `.apm/skills//`), a +plugin-root path (`docs/`, `bin/`), or a `../` chain that leaves the skill root. + +Resolve before flagging, twice over: + +- **Resolve the path.** `$SKILL_DIR/../assets/templates` climbs one level from a `scripts/` + directory and lands back inside the same skill, so it resolves in a cache install and is not a + finding. A bare `../` is not the defect; leaving the skill is. +- **Skip fenced code blocks.** A path inside a fenced block is an example, and rubrics quote outside + paths deliberately as negative examples of what not to write. Flag a fenced path only when the + surrounding prose presents it as the form to copy. **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 @@ -39,9 +49,10 @@ 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. + not runtime references. They are expected to be unresolvable after install, so + `validate-provenance.sh` does not treat an absent path as a FAIL — it emits an INFO naming the + slug and stating that checks 7 and 8 did not run for it. Flagging them as broken references + 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. @@ -60,19 +71,5 @@ The skill has to agree with itself. Three checks: 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md new file mode 100644 index 0000000..05eb85d --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md @@ -0,0 +1,138 @@ +--- +source_keys: + - agentskills-spec + - agentskills-best-practices + - agentskills-optimizing-descriptions + - agentskills-using-scripts +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +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. +- **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. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0 of + `references/description-quality.md`. +- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent + of the 400-character house ceiling above. + +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. + +**An unresolved boundary target is not graded here.** `validate.sh` owns that call and tiers it by +notation — `/name` or an arrow form is an ERROR, the bare prose form a SUGGESTION unless a second +target in the same sentence resolves — and Step 1 has already filed it under `### Structure` at that +tier. Re-grading it as a description FAIL puts one target in the report twice at two tiers. What is +left to judgment here 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. + +## body-discipline — `references/body-discipline.md` + +Flag as FAIL if: + +- 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 that every branch reaching the Gotcha also + reaches +- 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: + +- 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 +- Content that only one branch reaches is inlined where a `references/` file would serve + +## patterns — `references/patterns.md` + +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 + +## file-structure and internal-consistency — `references/file-structure.md` + +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 path that resolves outside the skill directory appears outside the two exempt locations, in + prose rather than in a fenced example +- `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 + +## formatting and scripts — `references/formatting-and-scripts.md` + +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/formatting-and-scripts.md b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md index 9462bdf..0b9d23e 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md @@ -44,20 +44,5 @@ follow from that: 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md index fb144ca..80cffa1 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md @@ -50,19 +50,5 @@ forms are judgment. `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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md b/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md index fd89ca7..a01c5dd 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, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.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/finding-criteria.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, references/patterns.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-optimizing-descriptions @@ -31,7 +31,7 @@ - **URL:** https://agentskills.io/skill-creation/optimizing-descriptions.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** How to systematically test and improve skill descriptions for triggering accuracy — eval queries, trigger rate testing, train/validation splits, optimization loop -- **Contributing files:** SKILL.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/description-quality.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-evaluating-skills @@ -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, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/finding-criteria.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 index 89c7788..a1ddb24 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md @@ -99,9 +99,12 @@ Three ways to read the result wrong: ## 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. +- **`validate-provenance.sh` printed nothing *and exited 0*.** That is a pass, not a skip — it + exits 0 silently when the skill has no `source_keys` and no `references/sources.md`, and nothing + to validate is not a finding. Check the exit code before you believe the silence: a target that + is not a directory, a directory holding no `SKILL.md`, a missing or extra argument, and an absent + `python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it + as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings. - **`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. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh index edfe211..4ed3629 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh @@ -13,17 +13,37 @@ Arguments: Exit codes: 0 All checks passed (or nothing to validate) 1 One or more checks failed + 2 Usage error, or the argument is not a skill directory + +An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a +non-zero exit as findings, so a usage error leaving exit 1 with nothing on +stdout was indistinguishable from a clean-but-failing run. Environment and +argument problems exit 2; only real findings exit 1. Checks performed: 0 source_keys present but references/sources.md absent 1 FILL IN: placeholders in sources.md 2 source_keys in SKILL.md → slug exists in sources.md - 3 source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) - 4 Contributing files listed in sources.md exist on disk + 3 source_keys in references/*.md → slug exists in sources.md (INFO if no + source_keys; an explicit 'source_keys: []' declares the file house-authored + and passes silently) + 4 Contributing files listed in sources.md exist on disk. An explicit + '(none)' skips silently; a Contributing files block this parser cannot + read is reported as an INFO saying checks 4 and 5 did not run, never + skipped silently. 5 Contributing files back-reference the parent slug in their source_keys 6 Research doc field present and not placeholder - 7 Slug in sources.md present in upstream research doc (INFO only) + 7 Slug in sources.md present in upstream research doc (INFO only). A section + annotation ('§ ...', '→ ...', '(...)') is stripped before the path is + resolved; a path that still does not resolve is reported as an INFO saying + checks 7 and 8 did not run, never skipped silently. 8 Extracted non-(none) slug in research doc present in sources.md + + Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE + INDEX — a file whose basename is sources.md, whose H2 headings ARE source + slugs. A Research doc pointing at a topic document is reported as an INFO + saying the two checks are not applicable, and every other reason they do not + run is announced the same way. EOF } @@ -32,11 +52,57 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then exit 0 fi +# Usage and environment problems exit 2, findings exit 1. See the usage text +# above for why the two must not share a code. This is a deliberate divergence +# from validate.sh, which has no 2 tier: validate.sh always prints PASS lines, +# so a usage error there is visibly not a findings report. This script prints +# NOTHING on a clean run, so exit 1 plus empty stdout was the only signal a +# caller got either way. if [[ $# -lt 1 ]]; then echo "Error: skill-dir is required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 +fi + +# Extra positional arguments were silently dropped, so a typo'd flag or a second +# path looked like it had been honoured. +if [[ $# -gt 1 ]]; then + echo "Error: expected exactly one argument, got $#: $*" >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + +# python3 is a HARD dependency. Without this preflight a missing interpreter +# produced 'line NN: python3: command not found' and exit 127 — an exit code no +# caller maps to anything, from a message that names this script's line number +# rather than the missing dependency. +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 provenance checks entirely 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 2 +fi + +# A path that is not a directory, or a directory that is not a skill, used to +# reach the Python body, find no sources.md and no source_keys, take the +# "nothing to validate" early exit and report exit 0 with no output — which +# references/validation-scripts.md explicitly told the auditor to read as a +# pass. A typo'd target was therefore indistinguishable from a clean skill. +# vale-wrap.sh hard-errors on a nonexistent path for exactly this reason. +if [[ ! -d "$1" ]]; then + echo "Error: not a directory: $1" >&2 + echo " Why: a nonexistent target would otherwise report a silent pass." >&2 + echo " Fix: pass the path of the skill directory to validate." >&2 + exit 2 +fi + +if [[ ! -f "$1/SKILL.md" ]]; then + echo "Error: not a skill directory (no SKILL.md): $1" >&2 + echo " Why: a directory with no SKILL.md has no provenance chain to validate, and reporting that as a pass hides the wrong-target mistake." >&2 + echo " Fix: pass the skill directory itself, not its parent or its references/ subdirectory." >&2 + exit 2 fi python3 -u - "$1" <<'PYTHON' @@ -44,13 +110,67 @@ import sys import os import re +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and every finding this script prints contains an em dash. +# Pinning only the reads moved the crash from the read to the write — a +# UnicodeEncodeError inside print_findings(), which loses the whole report +# after all the checks have already run. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) sources_md_path = os.path.join(skill_dir, "references", "sources.md") refs_dir = os.path.join(skill_dir, "references") # --- Helpers --- -PLACEHOLDER_RE = re.compile(r'(? "references/a.md" + return re.sub(r'\s*\(.*$', '', entry).strip() + + # Inline form: value on the same line, comma-separated, no notes. cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE) + if cf_m: + value = cf_m.group(1).strip() + if value.startswith("(none"): + return [] + return [p for p in (strip_note(x) for x in value.split(",")) + if p] or None + + # Bullet form: heading on its own line, one file per following bullet. + cf_m = re.search(r'^\*\*Contributing files:\*\*\s*$', block, re.MULTILINE) if not cf_m: return None - return cf_m.group(1).strip() + files = [] + for line in block[cf_m.end():].splitlines(): + line = line.strip() + if not line: + if files: + break + continue + if not line.startswith("- "): + break + entry = line[2:].strip() + if entry.startswith("(none"): + return [] + entry = strip_note(entry) + if entry: + files.append(entry) + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== -def parse_research_doc(content, slug): - """Find the Research doc value for a given slug H2 in content.""" +def parse_research_docs(content, slug): + """Every Research doc value under a given slug H2, in document order. + + The caller uses the first and reports the rest. Returning only the first — + what this did before — meant a second '- **Research doc:**' line in one + entry was silently ignored, so an author who added a doc rather than + replacing one got checks 7 and 8 run against the old path and no hint that + the new one was never looked at. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL ) m = pattern.search(content) if not m: - return None + return [] block = m.group(1) - rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE) - if not rd_m: - return None - return rd_m.group(1).strip() + return [v.strip() for v in + re.findall(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)] + +# A Research doc value is a path, and very often a path PLUS an annotation +# naming the section the slug came from: +# +# plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) +# plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)` +# .../pre-commit/hooks-reference.md § "pre-commit-hooks (official collection)" +# +# os.path.isfile() is false for every one of those strings, and checks 7 and 8 +# used to skip SILENTLY whenever the path did not resolve. The effect was that +# both checks were dead on eight of the nine git skills — git-history, the one +# skill writing a bare path, was the only place they ran, which is why it was +# the only skill ever reporting a check-7 INFO. Strip the annotation before +# resolving, and report when the result still does not resolve: a check that +# quietly does not run is worse than one that fails. +RESEARCH_DOC_ANNOTATION_RE = re.compile(r'[§→(]') + +def strip_research_doc_annotation(value): + """Path part of a Research doc value, with any section annotation removed.""" + return RESEARCH_DOC_ANNOTATION_RE.split(value, maxsplit=1)[0].strip() + +def research_doc_is_none(value): + """True when a Research doc value declares that no research doc backs the slug. + + Both '(none)' and the bare 'none — org convention, ...' spelling are in + use; recognising only the parenthesised one would report the other as an + unresolvable path. Checked BEFORE the annotation strip, because '(none)' + is itself a parenthesis and would strip to the empty string. + """ + return re.match(r'\(?none\b', value.strip(), re.IGNORECASE) is not None + +# The Status value is what gates check 8, so every spelling this parser fails +# to read is a check that does not run. Two were unreadable: +# +# - **Status:** `extracted` — partial fetch (a trailing note) +# **Status:** (the bullet form, the same +# - `extracted` shape parse_contributing_files +# already accepts) +# +# Both used to parse to a string that compared unequal to "`extracted`", and +# check 8 skipped on that inequality without a word. Returning the BACKTICKED +# TOKEN — not the whole line — is what makes the trailing note harmless, and it +# lets the caller name the actual status when it announces a skip. +STATUS_TOKEN_RE = re.compile(r'^`([^`]*)`') + def parse_status(content, slug): - """Find the Status value for a given slug H2 in content.""" + """Find the Status value for a given slug H2 in content. + + Returns the status with its backticks stripped ('extracted', 'referenced', + 'no content extracted'), or None when the entry has no Status line. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -134,10 +410,28 @@ def parse_status(content, slug): if not m: return None block = m.group(1) + + raw = None st_m = re.search(r'^\- \*\*Status:\*\* (.+)$', block, re.MULTILINE) - if not st_m: - return None - return st_m.group(1).strip() + if st_m: + raw = st_m.group(1).strip() + else: + st_m = re.search(r'^\*\*Status:\*\*\s*$', block, re.MULTILINE) + if not st_m: + return None + for line in block[st_m.end():].splitlines(): + line = line.strip() + if not line: + continue + if not line.startswith("- "): + break + raw = line[2:].strip() + break + if raw is None: + return None + + token = STATUS_TOKEN_RE.match(raw) + return token.group(1).strip() if token else raw def find_repo_root(start_dir): """Walk up from start_dir until we find a directory containing .git.""" @@ -153,13 +447,22 @@ def find_repo_root(start_dir): findings = [] has_fail = False +# A finding identical in every field is the same finding, and the same file is +# now reached by more than one check — the walk that looks for source_keys and +# check 3 both read every references/*.md, so an unreadable one would otherwise +# be reported twice with the same words. Distinct findings about the same file +# still both appear. +def _record(entry): + if entry not in findings: + findings.append(entry) + def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix, None)) + _record(("FAIL", desc, fpath, why, fix, None)) def emit_info(desc, fpath, note): - findings.append(("INFO", desc, fpath, None, None, note)) + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): for entry in findings: @@ -181,11 +484,22 @@ def print_findings(): # --- Scan for any file with source_keys --- -def file_has_source_keys(fpath): +def emit_unreadable(rel, exc): + """Report a file this script cannot decode. Never a silent skip.""" + emit_fail( + f"File is {exc}", + rel, + f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — " + f"cannot be read. This used to be swallowed by a bare 'except Exception: return False', " + f"which reported the unreadable file as having no source_keys and therefore as clean.", + f"Re-save '{rel}' as UTF-8." + ) + +def file_has_source_keys(fpath, rel): try: - with open(fpath) as f: - content = f.read() - except Exception: + content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) return False fm, _ = parse_frontmatter(content) if fm is None: @@ -198,26 +512,33 @@ def find_files_with_source_keys(): for root, dirs, files in os.walk(skill_dir): # Skip hidden dirs dirs[:] = [d for d in dirs if not d.startswith('.')] - for fname in files: + for fname in sorted(files): if fname.endswith('.md'): abs_path = os.path.join(root, fname) - if file_has_source_keys(abs_path): - rel = os.path.relpath(abs_path, skill_dir) + rel = os.path.relpath(abs_path, skill_dir) + if file_has_source_keys(abs_path, rel): results.append((rel, abs_path)) return results sources_md_exists = os.path.isfile(sources_md_path) files_with_source_keys = find_files_with_source_keys() -# Early exit: nothing to validate +# Early exit: nothing to validate. The scan itself can raise a finding — an +# unreadable file — so print before leaving; the clean case still prints +# nothing and exits 0. if not sources_md_exists and not files_with_source_keys: - sys.exit(0) + print_findings() + sys.exit(1 if has_fail else 0) # Load sources.md if it exists sources_content = None if sources_md_exists: - with open(sources_md_path) as f: - sources_content = f.read() + try: + sources_content = read_text(sources_md_path) + except EncodingError as exc: + emit_unreadable("references/sources.md", exc) + print_findings() + sys.exit(1) sources_slugs = set(parse_h2_slugs(sources_content)) else: sources_slugs = set() @@ -248,8 +569,11 @@ for line in sources_content.splitlines(): # --- Check 2: source_keys in SKILL.md → slug exists in sources.md --- skill_md_path = os.path.join(skill_dir, "SKILL.md") if os.path.isfile(skill_md_path): - with open(skill_md_path) as f: - skill_content = f.read() + try: + skill_content = read_text(skill_md_path) + except EncodingError as exc: + emit_unreadable("SKILL.md", exc) + skill_content = "" skill_fm, _ = parse_frontmatter(skill_content) skill_source_keys = parse_source_keys(skill_fm) for slug in skill_source_keys: @@ -262,24 +586,43 @@ if os.path.isfile(skill_md_path): ) # --- Check 3: source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) --- +# os.walk, not os.listdir: find_files_with_source_keys() above already walks +# references/ recursively, so a source_keys-bearing file in +# references// was collected there — and then never validated here, +# because the flat listdir could not see it. The two halves of the same check +# disagreed about which files exist. if os.path.isdir(refs_dir): - for fname in sorted(os.listdir(refs_dir)): - if not fname.endswith('.md'): - continue - if fname == "sources.md": - continue - fpath = os.path.join(refs_dir, fname) + ref_paths = [] + for root, dirs, files in os.walk(refs_dir): + dirs[:] = sorted(d for d in dirs if not d.startswith('.')) + for fname in sorted(files): + if not fname.endswith('.md'): + continue + fpath = os.path.join(root, fname) + if os.path.relpath(fpath, refs_dir) == "sources.md": + continue + ref_paths.append(fpath) + for fpath in ref_paths: rel = os.path.relpath(fpath, skill_dir) - with open(fpath) as f: - ref_content = f.read() + try: + ref_content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) + continue ref_fm, _ = parse_frontmatter(ref_content) ref_keys = parse_source_keys(ref_fm) if not ref_keys: + # An explicit `source_keys: []` is a deliberate declaration that + # the file is house-authored, and passes silently. The INFO is for + # files that never said either way. + if declares_empty_source_keys(ref_fm): + continue emit_info( f"No source_keys frontmatter", rel, "This references file has no source_keys — provenance cannot be verified. " - "Add source_keys frontmatter listing the slugs from references/sources.md that informed this file." + "Add source_keys frontmatter listing the slugs from references/sources.md that informed this file, " + "or declare an explicit 'source_keys: []' if the file is house-authored and has no external source." ) else: for slug in ref_keys: @@ -296,14 +639,51 @@ if os.path.isdir(refs_dir): repo_root = find_repo_root(skill_dir) # Collect all research doc paths we'll check (for Check 8) -research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it +research_docs_seen = {} # abs_path → (rel_path, slugs referencing it, content) -for slug in parse_h2_slugs(sources_content): - # Check 4: Contributing files exist - cf_value = parse_contributing_files(sources_content, slug) - if cf_value and not cf_value.startswith("(none"): - # Split by comma - cf_files = [p.strip() for p in cf_value.split(",") if p.strip()] +# Every per-slug parser below — parse_contributing_files, parse_research_docs, +# parse_status — locates its block with pattern.search(), so a slug written +# twice resolves to the FIRST block every time. Iterating the raw heading list +# therefore checked the first block's fields twice and the second block's +# never: a duplicated slug is half-validated, and looked fully validated. The +# duplicate is announced and the repeat visit dropped. +all_slugs = parse_h2_slugs(sources_content) +unique_slugs = [] +for _slug in all_slugs: + if _slug in unique_slugs: + continue + unique_slugs.append(_slug) + _count = all_slugs.count(_slug) + if _count > 1: + emit_info( + f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked", + f"references/sources.md (## {_slug})", + f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the " + f"second and later blocks' Contributing files, Research doc and Status are never validated — " + f"checks 4, 5, 6, 7 and 8 did not run for them. " + f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys." + ) + +for slug in unique_slugs: + # Checks 4 and 5: Contributing files exist, and back-reference the slug. + # `[]` and None are NOT the same answer here. `[]` is the author writing + # "(none)" — there is nothing to check and the skip is correct. None is a + # Contributing-files block this parser cannot read, and skipping THAT + # silently disables both checks on the one entry least likely to be right, + # which is the failure mode parse_contributing_files' own docstring warns + # about. Say so out loud instead, the same way an unresolvable Research doc + # value does. + cf_files = parse_contributing_files(sources_content, slug) + if cf_files is None: + emit_info( + f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed", + f"references/sources.md (## {slug})", + f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. " + f"Checks 4 and 5 did not run for this slug, so nothing verified that its contributing files exist or name it back. " + f"Write the value as '- **Contributing files:** ', or as a '**Contributing files:**' heading followed by '- ' bullets — " + f"or record '(none)' if this source contributed no files." + ) + elif cf_files: for cf_rel in cf_files: cf_abs = os.path.join(skill_dir, cf_rel) if not os.path.isfile(cf_abs): @@ -318,8 +698,11 @@ for slug in parse_h2_slugs(sources_content): # Skip sources.md itself if cf_rel == "references/sources.md": continue - with open(cf_abs) as f: - cf_content = f.read() + try: + cf_content = read_text(cf_abs) + except EncodingError as exc: + emit_unreadable(cf_rel, exc) + continue cf_fm, _ = parse_frontmatter(cf_content) cf_keys = parse_source_keys(cf_fm) if slug not in cf_keys: @@ -331,7 +714,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 6: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"references/sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; checks 7 and 8 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( f"Research doc field missing", @@ -346,39 +739,115 @@ for slug in parse_h2_slugs(sources_content): f"The '## {slug}' entry has an unfilled Research doc value.", f"Set '- **Research doc:**' to a real path relative to repo root, or '(none)' if not applicable." ) - else: - # Check 7: Upstream forward — slug should appear in research doc - if repo_root and not rd_value.startswith("(none"): - rd_abs = os.path.join(repo_root, rd_value) - if os.path.isfile(rd_abs): - with open(rd_abs) as f: - rd_content = f.read() + elif not research_doc_is_none(rd_value): + # Check 7: Upstream forward — slug should appear in research doc. + # Every path out of here that does NOT run the check says so out loud. + rd_path = strip_research_doc_annotation(rd_value) + if not repo_root: + emit_info( + f"Upstream checks skipped for '{slug}' — no repo root above the skill directory", + f"references/sources.md (## {slug})", + f"'{rd_value}' is a path relative to the repo root, but no ancestor of the skill directory contains a .git entry, " + f"so it cannot be resolved. Checks 7 and 8 did not run for this slug. " + f"Run this script against a skill inside a checkout." + ) + elif not rd_path: + emit_info( + f"Upstream checks skipped for '{slug}' — Research doc value names no path", + f"references/sources.md (## {slug})", + f"The Research doc value '{rd_value}' is entirely annotation — stripping the section marker leaves no path. " + f"Checks 7 and 8 did not run for this slug. " + f"Give the value a file path relative to the repo root, or record '(none)' if no research doc backs this entry." + ) + else: + rd_abs = os.path.join(repo_root, rd_path) + if not os.path.isfile(rd_abs): + emit_info( + f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' does not exist", + f"references/sources.md (## {slug})", + f"'{rd_value}' resolves to '{rd_path}' relative to the repo root and no file is there. " + f"Checks 7 and 8 did not run for this slug, so nothing verified that the research doc still backs it. " + f"Point the value at one existing file — a brace expansion, a comma-separated list of paths, or a bare section title does not resolve — " + f"or record '(none)' if no research doc backs this entry." + ) + elif os.path.basename(rd_path) != "sources.md": + # Checks 7 and 8 both assume the Research doc is a research + # SOURCE INDEX — a sources.md whose H2 headings ARE source + # slugs. 30 of the 121 corpus entries point instead at a TOPIC + # DOCUMENT (remotes.md, gitflow.md, api-reference.md), whose + # H2s are headings like '## Core Philosophy'. A slug can never + # match one, so check 7 reported all 30 as "slug not found" — + # every one a false positive — and check 8, aimed at documents + # that carry no '- **Status:**' line at all, was saved from a + # matching flood of false FAILs only by an UNANNOUNCED skip on + # that missing status. The premise, not the corpus, was wrong. + # + # A topic-document reference is a legitimate, useful value; it + # just is not something these two checks can verify. Say that + # once, out loud, instead of failing 30 entries for it. + emit_info( + f"Upstream checks not applicable for '{slug}' — research doc '{rd_path}' is a topic document, not a source index", + f"references/sources.md (## {slug})", + f"Checks 7 and 8 match slugs against the H2 headings of a research source index — a file named 'sources.md', " + f"where each H2 IS a source slug. '{os.path.basename(rd_path)}' is a topic document, so its H2s are section " + f"headings and no slug will ever match one. Checks 7 and 8 did not run for this slug. " + f"This needs no fix: point the value at the research corpus's own sources.md only if you want the " + f"provenance link machine-verified." + ) + else: + try: + rd_content = read_text(rd_abs) + except EncodingError as exc: + emit_info( + f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' is {exc}", + f"references/sources.md (## {slug})", + f"'{rd_path}' could not be decoded, so checks 7 and 8 did not run for this slug. " + f"Re-save the research doc as UTF-8." + ) + continue rd_slugs = set(parse_h2_slugs(rd_content)) if slug not in rd_slugs: emit_info( - f"Slug '{slug}' not found as H2 in research doc '{rd_value}'", + f"Slug '{slug}' not found as H2 in research doc '{rd_path}'", f"references/sources.md (## {slug})", - f"The research doc '{rd_value}' does not have a '## {slug}' heading. " + f"The research doc '{rd_path}' does not have a '## {slug}' heading. " f"The provenance link may be imprecise — the slug name in sources.md may differ from the research doc's heading." ) - # Track for Check 8 + # Track for Check 8. The content is carried with the entry so + # check 8 reuses this read rather than decoding the file a + # second time, with a second chance to fail differently. if rd_abs not in research_docs_seen: - research_docs_seen[rd_abs] = (rd_value, set()) + research_docs_seen[rd_abs] = (rd_path, set(), rd_content) research_docs_seen[rd_abs][1].add(slug) # --- Check 8: Upstream reverse --- -for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): - with open(rd_abs) as f: - rd_content = f.read() +for rd_abs, (rd_rel, known_slugs, rd_content) in research_docs_seen.items(): for rd_slug in parse_h2_slugs(rd_content): # Parse this slug's Contributing files and Status in the research doc rd_cf = parse_contributing_files(rd_content, rd_slug) rd_status = parse_status(rd_content, rd_slug) - # Skip if contributing files start with (none - if rd_cf and rd_cf.startswith("(none"): + # Skip if the research doc explicitly records no contributing files + if rd_cf == []: continue - # Skip if status is not `extracted` - if rd_status != "`extracted`": + # Skip if status is not `extracted` — and say so when the skip is what + # kept the slug out of the FAIL below. A status of `referenced` or + # `no content extracted` is a real reason not to demand the slug, but + # it was applied in silence, so an entry that should have been in + # sources.md and a status line nobody had updated produced the same + # output: nothing. Only a MATERIAL skip is announced; when the slug is + # already in sources.md the check passes either way and there is no + # fail-open to disclose. + if rd_status != "extracted": + if rd_slug not in sources_slugs: + shown = f"`{rd_status}`" if rd_status else "absent" + emit_info( + f"Check 8 skipped for research-doc slug '{rd_slug}' — its Status is {shown}, not `extracted`", + f"{rd_rel} (## {rd_slug})", + f"'{rd_rel}' has '## {rd_slug}' with contributing files but Status {shown}, and this skill's " + f"sources.md has no '## {rd_slug}' entry. Check 8 only demands an entry for an `extracted` slug, " + f"so it did not run here. If that status is stale — the content was extracted and the line was never " + f"updated — this skill is missing a source entry; if it is accurate, nothing needs doing." + ) continue # This slug should be in sources.md if rd_slug not in sources_slugs: diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index f9c0df7..e5ce8f3 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -59,6 +59,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") @@ -179,9 +197,27 @@ def _collect_package(pkg_dir, names): 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()) + # A directory is a skill only if it HOLDS a SKILL.md. An empty + # leftover — a deleted skill whose directory survived, a scaffolding + # stub, an editor's stray mkdir — is untracked by git, so it exists + # on the machine that made it and nowhere else. Counting it made a + # boundary target resolve locally and dangle in a fresh clone: the + # same install-dependence the deployed-tree rule above exists to + # remove, arriving through a different door. + if os.path.isfile(os.path.join(path, 'SKILL.md')): + 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)): + # The same rule one directory over, which until now had no + # counterpart here at all: the skills branch above tests for a + # SKILL.md, the agents branch took every glob hit on trust. A + # DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not + # tell the two apart, so a leftover of that shape resolved a routing + # target on the machine holding it and dangled everywhere else — + # identical install-dependence, arriving through the one door + # nobody guarded. + if not os.path.isfile(path): + continue base = os.path.basename(path) if base.endswith('.agent.md'): base = base[:-len('.agent.md')] @@ -372,8 +408,17 @@ def known_targets(start_dir): # 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`. +# `Not -> `. The example that motivated it is gone: +# diagnose's process chain "fix -> regression-test", which without the +# gate read as a route to a non-existent `regression-test` skill, was cut +# when issue #99 retrofitted that description. So the gate is currently +# UNEXERCISED — gating and not gating produce the same verdict corpus-wide. +# Keep it anyway. It is a false-positive guard against prose no one has +# written yet, and any new process chain re-arms it. Unexercised is not the +# same as unnecessary, and the branch it guards is still load-bearing: the +# bare-arrow rule is the sole extractor for three real targets in +# kyberforge's audit skills (agent-audit -> agent-author, agent-audit -> +# skill-audit, skill-audit -> skill-author), all written unbackticked. # * 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 @@ -451,6 +496,66 @@ def known_targets(start_dir): # ambiguity to resolve, and an author who wants a route checked unconditionally # has two ways to say so. # +# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise +# above rather than a widening of it. Until the sweeps existed, notation was +# only ever seen as the OBJECT OF A ROUTE VERB (`use +# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in +# between. Every one of these therefore exited 0 in total silence — no ERROR, no +# SUGGESTION, not even the target's name: +# Do not use for Y — /no-such-skill instead. +# Do not use for Y; /no-such-skill handles that. +# Do not use for Y (/no-such-skill covers it). +# Do not use for Y — that is /no-such-skill's job. +# Do not use for Y — defer to /no-such-skill. +# Do not use for Y — /no-such-skill. +# Do not use for Y; -> no-such-skill covers it. +# For W, /no-such-skill is the right entry point. +# The target was never EXTRACTED, so the notation-first rule in _add() had +# nothing to apply itself to and the "always blocks" promise was false for the +# ordinary way an author writes the thing. The SUGGESTION tier made it worse +# than a gap: its printed remedy tells the author to "write it as `/name` or +# `-> name` and it will be checked properly", and taking that advice turned a +# visible SUGGESTION into silence — the gate teaching the one edit that blinds +# it. +# +# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point. +# `/name` is Claude Code's invocation syntax and nothing else — no English +# sentence contains one by accident — so the ADR-0020 amendment and +# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So +# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it +# on BOUNDARY_MARKER made that promise false for the last sentence of +# Do not use for Z — use /real-skill instead. +# For W, /no-such-skill is the right entry point. +# which exited 0 in total silence: the boundary clause is one sentence up, so +# the sweep never looked at the sentence carrying the broken route. Extraction is +# per-sentence by design (corroboration is scoped to one sentence), which is +# exactly what made the gap invisible. +# +# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep. +# Neither form is unambiguous: `-> name` is also how a process chain is written +# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a +# file and a skill are all cited. Ungating either would fire on prose that +# carries no routing intent at all — the false-positive class this whole +# extractor is tuned against. +# +# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`, +# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or +# `https://x/y`, not a route. A sentence's closing `.` is not followed by a +# non-space, so `— /no-such-skill.` still counts. +# +# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at +# all here: it holds after a hyphen, so when the trailing lookahead rejected the +# full segment the engine simply backtracked to a shorter hyphen-terminated +# prefix and reported THAT as a route. Every one of these was a hard blocking +# ERROR naming a skill nobody had written: +# the config lives at /opt-tools/bin/thing. -> 'opt' +# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs' +# the file /no-such-skill.md documents it. -> 'no-such' +# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is +# rejected as the path it is. MARKED_TARGET carries the same guard: it had no +# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of +# the two errors above through the route-verb path rather than the sweep. +# # 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. @@ -461,7 +566,8 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY +# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see +# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse +# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route +# verb or an arrow, and it may match a namespaced or path-adjacent token in +# positions this free-standing sweep must refuse. +# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is +# what made `Do not use for Y; -> no-such-skill covers it.` invisible: +# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the +# check. Dropping that prefix costs the one false positive the bare-arrow bullet +# above names — a process chain ending in a hyphenated word, `Instead, reproduce +# -> minimise -> regression-test.` — and costs it only in a sentence that already +# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same +# chain written `Do not use for X — reproduce -> regression-test.` was already a +# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the +# arrow, not whether prose can. An author who means the chain and not a route +# writes it in its own sentence, where neither pattern looks. +NOTATION_SLASH = re.compile( + r"(?|→)\s*(%s)\b" % NAME_HYPH, re.I) +# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT +# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a +# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, +# `.vale.ini` — was invisible to both patterns below, and the two resulting +# failures were different sizes (issue #110): +# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep +# still extracted the target, so the route was checked, but the gate +# reported "no boundary clause" on a clause that was present and working. +# Three authors in two retrofit waves reworded a correct clause to satisfy +# the regex, one of them stripping the very filename that discriminates the +# skill from its neighbour. +# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only +# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill` +# produced no target, no dangling report and no missing-clause SUGGESTION. +# Silence, not noise — the worse of the two failure modes. +# A dot inside a filename is followed by a non-space; a sentence-ending dot is +# followed by whitespace or by end of string. So the class admits a `.` only +# when the next character is not whitespace, which crosses `AGENTS.md` and +# still stops at a real sentence end. +CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))" +ARROW_BOUNDARY = re.compile( + r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, 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) +BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, 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" @@ -499,9 +646,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) # 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. +# re.I here too, and NOT as a tidy-up: this was the one pattern in the file +# built without it, contradicting the uniformity note on CONT_*/ARROW_* above. +# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is +# where an abbreviation most often lands — matched none of the lookbehinds, so +# the clause split at the abbreviation, the corroborating target was stranded on +# the far side of the cut, and a genuinely dangling target silently demoted from +# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described +# directly above, still live for exactly the capitalised half of the input. SENTENCE_SPLIT = re.compile( u'(? name` reached this function with + strict=True from its two call sites, but `/name` did not, so it fell to + _terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The + target then reached unresolved_targets() unblockable — and, before the + companion fix there, unreported as well. `... use /no-such-skill + afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an + author who wants a route checked unconditionally. + """ if not name: return + notation = _notation(text, start, arrow) + if strict is None and notation: + strict = True out.append((name, _terminal(text, end) if strict is None else strict, - _notation(text, start, arrow))) + notation)) def _scan(text, route_re, cont_re, out): @@ -604,7 +774,19 @@ def _extract_sentence(sentence): for match in ARROW_BOUNDARY.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) + # `/name` wherever it sits, in ANY sentence — not only where a route verb or + # an arrow happens to precede it, and NOT only inside a boundary sentence. + # See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings + # this recovers and for why silence was the failure mode. The sweep takes no + # follower test: _add() reads the notation first and marks it. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) if boundary: + # The arrow and backtick forms are ambiguous in ordinary prose, so they + # stay scoped to a sentence that carries a boundary marker. + for match in NOTATION_ARROW.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) for match in BACKTICK.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1)) return out @@ -623,6 +805,85 @@ def boundary_targets(description): return sorted({name for name, _, _ in _extract(description)}) +def _arrow_targets(description): + """Names extracted from ARROW notation specifically. + + Kept apart from boundary_targets() because the arrow form is the one shape + that ALWAYS names a target: ADR-0020's `Not -> `. A clause + written that way from which nothing could be extracted is a parse failure + that deserves its own message, and telling it apart needs the arrow targets + alone rather than every target in the description. + """ + out = [] + for sentence in SENTENCE_SPLIT.split(description): + for match in ARROW_MARKED.finditer(sentence): + name, _, _ = _first(match) + if name: + out.append(name) + for match in ARROW_BOUNDARY.finditer(sentence): + out.append(match.group(1)) + return out + + +def boundary_clause_status(description): + """'absent', 'unparsed' or 'present' — three outcomes, not two. + + Issue #110's standing request: the gate must distinguish "no boundary + clause" from "boundary clause I could not parse". Reporting the first for + the second sends the author hunting for a problem that is not there, and + three of them reworded a correct clause to satisfy a regex instead. + + 'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was + detected and NO target came out of it. The arrow form always names one, so + zero targets means the name is written in a shape the extractor cannot see + — a single-word bare target (`Not X -> forge`, which has to be written + `` `forge` `` or `/forge`) is the live example, since single-word names are + deliberately not matchable bare. + + A PROSE clause yielding no target is NOT reported: "Do not use for anything + else" is a complete and legitimate boundary clause that names nowhere to go. + """ + if BOUNDARY_ARROW.search(description) and not _arrow_targets(description): + return 'unparsed' + if has_boundary_clause(description): + return 'present' + return 'absent' + + +def multi_target_arrow_clauses(description): + """[(first, second)] for arrow clauses naming more than one target. + + Issue #107: only the FIRST target after an arrow is resolved. The + conjunction continuation (CONT_*) is wired to the prose route verbs and + never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither + resolved nor reported, and then printed "1 of 1 boundary target(s) resolve" + on a clause naming two — a gate under-reporting its own coverage, which is + the one failure mode ADR-0020 says a gate must not have. + + The clause is REJECTED rather than the arrow scan extended. Extending it + would widen the resolver's deliberately conservative false-positive tuning + across every arrow in the corpus; rejecting costs nothing and makes the + one-arrow-per-target convention — already what every retrofitted gitea + skill does in practice — explicit instead of folkloric. The caller emits a + SUGGESTION telling the author to split. + """ + hits = [] + for sentence in SENTENCE_SPLIT.split(description): + matches = (list(ARROW_MARKED.finditer(sentence)) + + list(ARROW_BOUNDARY.finditer(sentence))) + for match in matches: + first, _, _ = _first(match) + if not first: + continue + cont = CONT_ANY.match(sentence, match.end()) + if not cont: + continue + second, _, _ = _first(cont) + if second: + hits.append((first, second)) + return hits + + def unresolved_targets(description, known): """Targets resolving to nothing, split into (blocking, reported). @@ -639,6 +900,17 @@ def unresolved_targets(description, known): 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. + + A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed + whitelist of maybe eighty words, so the follower rule says "this token is + outside a list I keep" and not "this is prose" — and the old `continue` + turned that into invisibility at every tier. The gate then failed OPEN on + its own unfamiliarity: any target followed by a word nobody thought to + enumerate was neither blocked nor mentioned, so the check that did not run + said nothing about not running. The follower rule may withdraw the power to + BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note + above is the argument for it — but it may not withdraw visibility, which is + the same rule the corroboration tier already follows. """ blocking, reported = set(), set() for sentence in SENTENCE_SPLIT.split(description): @@ -647,7 +919,10 @@ def unresolved_targets(description, known): 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: + if key in known: + continue + if not may_dangle: + reported.add(name) continue if notation or (resolved - {key}): blocking.add(name) @@ -727,6 +1002,47 @@ def description_value(fm_text): return re.sub(r'\s+', ' ', value).strip() +def hand_invoked(fm_text): + """True when the frontmatter marks this file as reached only by hand. + + `disable-model-invocation: true` removes a skill from the model-visible + listing entirely — it is not preloaded, and the Skill tool refuses to call + it — so its description is never matched against user intent. ADR-0020 and + skill-author's contract give such a skill ONE plain human-facing sentence: + no trigger list, no boundary clause. No validator knew the field existed + (issue #108), so the boundary-clause SUGGESTION fired on exactly the shape + the contract mandates, and its remedy — "add a boundary clause so the router + knows where NOT to send this skill" — was addressed to a router that cannot + see the skill at all. An author who followed the advice made the file worse. + + Only the ROUTING rules are lifted. The body word budget still applies: the + body is loaded on invocation like any other, and competes with the caller's + live conversation the same way. So does the 400-character description FAIL — + a hand-invoked description is not preloaded, but it is still the one line + the user reads when choosing from the `/` menu, and the ceiling is the + outlier stop rather than the style target. + + A parse failure returns False rather than raising. This is a MODIFIER on + other checks, not a check of its own: the frontmatter's validity is decided, + and failed, by description_value() on the same text, and raising a second + exception here would report one broken file twice with two different + diagnoses. + """ + try: + data = yaml.safe_load(fm_text) + except Exception: + return False + if not isinstance(data, dict): + return False + value = data.get('disable-model-invocation') + if isinstance(value, str): + # PyYAML already resolves the unquoted YAML 1.1 booleans, so this only + # catches a QUOTED "true" — which a host reads as truthy and which no + # gate should treat as opting back in to the routing rules. + return value.strip().lower() in ('true', 'yes', 'on') + return value is True + + # --- 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 @@ -841,7 +1157,15 @@ def missing_reference_pointers(body, skill_dir): end = masked.find('\n', match.end()) if end < 0: end = len(masked) - if REFERENCE_PAST.search(masked[start:end]): + # The pointer's OWN SPAN is excised before the sweep. Run over the + # whole line, the past-tense test matched the very path it was judging, + # so a file exempted itself by its NAME: `references/deprecated-api.md`, + # `references/removed-flags.md` and `references/gone.md` produced no + # ERROR at all, while `references/missing.md` — an identical break — + # errored. The exemption is about what the SENTENCE says about the + # pointer, never about what the pointer is called. + line = masked[start:match.start()] + masked[match.end():end] + if REFERENCE_PAST.search(line): continue if REFERENCE_QUALIFIER.search(masked[start:match.start()]): continue @@ -900,6 +1224,15 @@ except FrontmatterError as exc: dir_name = os.path.basename(skill_dir) +# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation: +# true` takes the skill out of the model-visible listing entirely, so the +# trigger/capability/boundary rules and the 250-character routing target do not +# apply to it — the audit's own references/description-quality.md Step 0 says +# so, and until this line existed no check here knew the field existed. What the +# flag does NOT lift: the body word budget and the 400-character description +# ceiling. See the shared resolver's hand_invoked(). +by_hand = hand_invoked(fm) + # --- Checks --- # name present @@ -1014,10 +1347,13 @@ if desc: 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: + elif dlen > DESC_SUGGEST_CHARS and not by_hand: 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") + elif by_hand: + ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character " + f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)") else: ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") @@ -1071,13 +1407,41 @@ if gotchas is not None: # 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): +# +# THREE outcomes, not two: "no boundary clause" and "boundary clause I could not +# parse" are different findings, and reporting the first for the second sends +# the author hunting for a problem that is not there (issue #110). +# +# Skipped entirely for a hand-invoked skill — the contract gives it one plain +# sentence with no boundary clause, so the finding would be wrong and its remedy +# names a router that cannot see the skill (issue #108). +if desc and by_hand: + ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger " + "rules do not apply; audited as one plain human-facing sentence") +elif desc: + status = boundary_clause_status(desc) + if status == 'present': ok("description has a boundary clause") - else: + elif status == 'absent': 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") + else: + suggest("description has an arrow boundary clause (\"Not X -> y\") from which no " + "target could be read, so the dangling-target check did not run on it — " + "the clause is PRESENT and unparsed, not missing. Most often the target is " + "a single word, which is deliberately not matchable bare because " + "`research`, `triage` and `forge` are all ordinary English: write it as " + "`name` or /name") + # One arrow, one target. A second name after the same arrow is resolved by + # nothing and reported by nothing, so the clause claims coverage it does not + # have and this script printed "1 of 1 boundary target(s) resolve" on a + # clause naming two (issue #107). + for first, second in multi_target_arrow_clauses(desc): + suggest(f"an arrow boundary clause names more than one target ('{first}', then " + f"'{second}') and only the first is resolved — the second is checked by " + f"nothing. Split it into one arrow per target: \"Not X -> {first}. " + f"Not Y -> {second}.\"") # --- ADR-0020: resolvable boundary targets --------------------------------- # The resolution universe comes from the SKILL's own location: the authoring diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats index 58dc65e..c5f7d95 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats @@ -64,6 +64,43 @@ EOF - **Contributing files:** ${contrib} - **Research doc:** ${research} - **Status:** \`extracted\` +EOF + } + + # Helper: create a fake repo (a .git marker makes find_repo_root stop there) + # holding one skill whose single sources.md slug points at the given + # Research doc value. Checks 7 and 8 only run for a skill inside a checkout, + # so every upstream case needs this shape; the research doc itself is + # written per test into "$repo/docs/research/sources.md". + make_upstream_skill() { + local repo="$1" + local research="${2:-docs/research/sources.md}" + local skill="$repo/my-skill" + mkdir -p "$skill/references" "$repo/docs/research" + touch "$repo/.git" + cat > "$skill/SKILL.md" < "$skill/references/sources.md" < "$research_dir/my-research.md" < "$research_dir/sources.md" < "$fake_repo/docs/research/my-research.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/my-research.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/my-research.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$skill/references/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$skill/references/extra.md" < "$skill/references/extra.md" < "$skill/references/extra.md" < "$skill/references/extra.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$skill/references/sources.md" < "$skill/references/sources.md" < "$skill/references/extra.md" < "$skill/references/extra.md" < "$fake_repo/docs/research/remotes.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$fake_repo/docs/research/sources.md" < "$skill/SKILL.md" + cat >> "$skill/SKILL.md" < "$skill/references/sources.md" < "$skill/references/topic.md" < "$skill/references/topic.md" + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "not valid UTF-8" + assert_output --partial "references/topic.md" +} + +# --------------------------------------------------------------------------- +# Cycle 24 — G4: check 3 walks references/ recursively +# --------------------------------------------------------------------------- + +@test "G4: a source_keys file in references// is validated, not skipped" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + mkdir -p "$skill/references/nested" + cat > "$skill/references/nested/topic.md" < "$skill/references/sources.md" < "$skill/references/sources.md" < "$skill/references/sources.md" < "$skill/references/topic.md" + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "not valid UTF-8" +} diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats index f63d799..3740033 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats @@ -70,13 +70,23 @@ PY # 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/skills/fixture-sibling-skill/SKILL.md # /plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md + # + # The sibling gets a real SKILL.md, and that is load-bearing rather than + # tidiness: a directory under skills/ is a resolvable name only when it + # HOLDS one. An empty leftover directory is untracked by git, so counting + # one made a target resolve on the machine that made it and dangle in a + # fresh clone. This helper used to mkdir the sibling and write nothing into + # it, so the corroborator every blocking-tier test depends on silently + # stopped resolving the moment that rule was enforced. 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" + make_sized_skill "$apm/skills/fixture-sibling-skill" \ + "Use when doing the other thing. Do not use for anything else." 10 echo "$apm/skills/$subject" } } @@ -568,3 +578,198 @@ EOF assert_output --partial "boundary-target resolution DID NOT RUN" assert_output --partial "Unchecked target(s): some-other-skill" } + +# --------------------------------------------------------------------------- +# ADR-0020 — the hand-invocation carve-out (issue #108) +# +# A skill carrying `disable-model-invocation: true` is absent from the +# model-visible listing entirely: not preloaded, and the Skill tool refuses to +# call it. Its description is never matched against user intent, so +# references/description-quality.md Step 0 gives it ONE plain human-facing +# sentence — no trigger list, no boundary clause — and calls a +# missing-boundary-clause finding on such a skill "a wrong finding, not a strict +# one". Until this ran, nothing here knew the field existed, so the audit +# reported exactly the shape its own rubric mandates, with advice naming a +# router that cannot see the skill. +# +# The carve-out is narrow. Both size gates are unaffected and both are pinned +# below: the body is loaded on invocation like any other body, and the +# 400-character ceiling is an outlier stop rather than a routing budget. +# --------------------------------------------------------------------------- + +# Helper: a skill directory carrying `disable-model-invocation: true`. +make_hand_invoked_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 "disable-model-invocation: true" + echo "---" + echo "" + python3 -c "print(' '.join(['word'] * $body_words))" + } > "$dir/SKILL.md" +} + +@test "ADR-0020: a hand-invoked skill is not asked for a boundary clause" { + local skill="$TMPDIR/my-skill" + make_hand_invoked_skill "$skill" \ + "Tell the agent to zoom out and give broader context or a higher level perspective." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "has no boundary clause" + assert_output --partial "hand-invoked" +} + +@test "ADR-0020: the SAME description without the flag IS asked for a boundary clause" { + # The control. Without it the case above is satisfied by an audit that + # stopped checking boundary clauses altogether. + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" \ + "Tell the agent to zoom out and give broader context or a higher level perspective." 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "has no boundary clause" +} + +@test "ADR-0020: a hand-invoked skill is exempt from the 250-character description target" { + local skill="$TMPDIR/my-skill" + make_hand_invoked_skill "$skill" \ + "$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 273)")" 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "over the 250-character" +} + +@test "ADR-0020: a hand-invoked description over 400 chars still FAILS" { + # The half the carve-out does NOT lift. 400 is an outlier stop, not a + # routing-quality target: a hand-invoked description is still the one line + # the user reads when choosing from the `/` menu. + local skill="$TMPDIR/my-skill" + make_hand_invoked_skill "$skill" \ + "$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 374)")" 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "400-character" +} + +@test "ADR-0020: a hand-invoked body over 900 words still FAILS" { + # The body is loaded on invocation exactly like any other body and competes + # with the caller's live conversation the same way, so no body tier moves. + local skill="$TMPDIR/my-skill" + make_hand_invoked_skill "$skill" "Tell the agent to zoom out." 901 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "900-word" +} + +# --------------------------------------------------------------------------- +# ADR-0020 — one arrow, one target (issue #107) +# +# Only the FIRST target after an arrow was resolved: the conjunction +# continuation is wired to the prose route verbs and never to arrows. So this +# script printed "1 of 1 boundary target(s) resolve" on a clause naming two, +# and the second was resolved by nothing and reported by nothing. A typo in it +# shipped through a green gate. The shape is now rejected rather than the +# extractor widened. +# --------------------------------------------------------------------------- + +@test "ADR-0020: an arrow clause naming two targets is reported, not silently half-checked" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # A bare `Not ... ->` sentence carries no BOUNDARY_MARKER, so the backtick + # sweep does not run and the second target is invisible to every other rule + # in the resolver — this is the exact shape #107 measured. + make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\` or \`fixture-missing-second\`." 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "names more than one target" +} + +@test "ADR-0020: one arrow per target — the convention the suggestion asks for — is silent" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\`." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "names more than one target" +} + +# --------------------------------------------------------------------------- +# ADR-0020 — a dotted filename in a boundary clause (issue #110) +# +# `[^.;]` could not cross the `.` in `AGENTS.md`, so a clause naming a dotted +# file between "Not" and the arrow was invisible. With a backticked target that +# was a MISDIAGNOSIS — "no boundary clause" reported on a clause that was +# present and working. With a BARE target it was worse: the target was never +# extracted, so the dangling check silently did not run on it. +# --------------------------------------------------------------------------- + +@test "ADR-0020: a boundary clause naming a dotted filename is not reported as missing" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> \`fixture-sibling-skill\`." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "has no boundary clause" + assert_output --partial "description has a boundary clause" +} + +@test "ADR-0020: a BARE target after a dotted filename is extracted and checked" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # The silent half of #110: this clause produced no target at all, so it was + # neither resolved nor reported — a route to a non-existent skill shipping + # through a green gate with no finding of any kind. + make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> fixture-missing-dotted." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-dotted'" +} + +@test "ADR-0020: an arrow clause yielding no target is reported as unparsed, not as missing" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # A single-word target is deliberately not matchable bare, because + # `research`, `triage` and `forge` are all skill names AND ordinary English. + # The clause is present; saying it is missing sends the author to add a + # second copy of a clause that is already there. + make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> forge." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "has no boundary clause" + assert_output --partial "no target could be read" +} + +# --------------------------------------------------------------------------- +# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8') +# +# read_text() in the shared resolver block pins the READS to UTF-8. That moved +# the LC_ALL=C crash to the WRITE: this script's own message text carries em +# dashes (the ADR-0020 boundary SUGGESTION is one), so the streams' ASCII +# default raised UnicodeEncodeError while PRINTING — after every check had +# already run, losing the whole report at the last step. +# --------------------------------------------------------------------------- + +@test "under LC_ALL=C the report is printed, not lost to a UnicodeEncodeError" { + local dir="$TMPDIR/locale-skill" + mkdir -p "$dir" + cat > "$dir/SKILL.md" < name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the SKILL.md +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 skill'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 boundary clause naming a target -outside that universe sends the router nowhere and fails the audit. Check the target exists before -writing it — do not invent a plausible sibling name. +outside that universe sends the router nowhere — a blocking failure in arrow or `/name` form, and +in prose form a SUGGESTION nobody is forced to act on, which is the worse outcome because it ships. +Check the target exists before writing it — do not invent a plausible sibling name. 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 @@ -63,6 +70,18 @@ or Codex, and `.apm/` source compiles for all three, so routing to one is a port 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. +**One arrow, one target.** The resolver reads only the first name after an arrow, so a second is +checked by nothing and the gate emits a SUGGESTION naming both. Split instead of conjoining: +`Not -> first-skill. Not -> second-skill.`, never +`Not -> first-skill or second-skill`. + +**Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding +replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports it dangling — this is what broke +`gitea-labels-milestones`, and nothing in the source lines looks wrong. Reflow so the whole name +sits on one line. The same applies to any backticked skill or agent name anywhere in a description. + **Length.** 250 characters SUGGESTION, 400 characters FAIL, counting the frontmatter value only with YAML folding resolved. The agentskills.io 1,024-character spec limit is unchanged and sits above both. The SUGGESTION tier is the one that moves the average; treat 250 as the target and 400 @@ -118,11 +137,25 @@ If , read `references/.md`. A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it. +**A dispatch table is the wiring.** Where the body dispatches, a row already pairs a condition with +a target, which is what the literal form encodes — so do not restate each row underneath as a prose +conditional. That duplicates the routing in the one body whose whole purpose is to be short. The +literal form is what a reference loaded *without* a table needs: a mid-procedure deepening, an +escape hatch, an error path. A table earns this on four properties — every flow has a row and every +row's target exists on disk; each row pairs exactly one target with a condition the agent can +evaluate from the request, never a literal slash invocation; one line after the table names the +matched file as the only one to read; and the gates every branch needs sit in the body, not inside +one flow's file. That last one is the property the `git-commits` v0.1.2 failure turned on, and it is +the one a dispatch split is most likely to break. `skill-audit`'s `references/body-discipline.md` +carries the audit-side form of the same exemption; the two lists are the same four properties, and +an edit to either belongs in both. + **Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch table and the gates common to every branch; each flow gets its own self-contained `references/` -file. Exemplar: the `apm-workflow` skill — a **421-word body** dispatching to 3,006 words of -references. Calibrate against 421: that file's whole-file count is 554 words, and aiming at that -number instead overshoots the body budget by ~30%. +file. Exemplar: the `apm-workflow` skill — a **294-word body** dispatching to 3,154 words of +references across five flow files. Calibrate against 294: that file's whole-file count is 348 +words, and aiming at that number instead overshoots the body budget by ~18%. The 3,154 excludes +`references/sources.md`, which is a provenance record and is never loaded at runtime. **Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after the frontmatter's closing `---`. @@ -130,10 +163,20 @@ the frontmatter's closing `---`. ## Gotchas section - Each entry must state a fact that **contradicts a reasonable default** — something the agent - gets wrong by acting sensibly. "Never commit secrets" is not one; the agent already knows. + gets wrong by acting sensibly. "Write a descriptive commit message" is not one; the agent does it + unprompted and nothing in the environment argues against it. A safety gate is a different case, + even where the agent knows the rule — see the paraphrase bullet below. - More than five entries is a SUGGESTION — five is the guideline, not a ceiling. -- A Gotcha that paraphrases a step in the body below it is a **FAIL**. If the rule is already a - step, it is not a gotcha. +- A Gotcha that paraphrases a step in the body below it is a **FAIL**, but deleting it is correct + only when the surviving copy is **reachable from every branch that reaches the Gotcha**. In a + dispatch body it often is not: each flow file loads alone, so a step in one is invisible to an + invocation that took another branch. Where the restated rule is a safety gate more than one flow + needs, move it into the body's common-gates section instead of dropping it. `git-commits` v0.1.2 + is the worked failure: the retrofit deleted its always-loaded "never commit secrets" Gotcha in + favour of a step in one flow file, and left the history-rewrite branch — which stages and + `--amend`s, committing new content exactly as a fresh commit does — with no such check anywhere + in its loaded context, against this repo's governance hard prohibitions. v0.1.3 carries the rule + as a gate on every flow. - A Gotchas section exceeding 25% of the body is a SUGGESTION. - Place the section near the top — a gotcha read after the mistake is worthless. diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md index 7eeae03..37fa55d 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md @@ -96,6 +96,13 @@ them for you. After every retrofit that adds, removes or renames a file: content moved into it, and remove any file the retrofit deleted. This is the one that gets missed: `sources.md` keeps citing sections of `SKILL.md` that no longer exist, the provenance check still exits 0, and the stale claim survives review. +- [ ] **Reachability of every relocated gate.** For each Gotcha or gate the retrofit moved out of + the body, list the flows that need it and confirm each one reaches the surviving copy. A gate + that lands in a single flow file is invisible to every other branch, and no gate detects + that: `/skill-audit` reads whichever file it was handed, and the word counts improve either + way. Where more than one flow needs it, the copy belongs in the body's common-gates section, + not in a flow file. Grep the skill for the gate's key term and check every branch that hits + zero. - [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new file as missing `source_keys`. @@ -115,14 +122,19 @@ milestone), that's gitea-labels-milestones directly. Do not use for pull request or for local git branch/commit work (use gitea-branches or git-branches). ``` -After, 240 characters: +After, the 290 characters that shipped: ```text -Use when reading or writing Gitea issues — list, read, create, comment on, label, close, or -search — even when the user does not say "Gitea". Not pull requests -> `gitea-prs`. Not label or -milestone definitions -> `gitea-labels-milestones`. +Use when reading or writing Gitea issues — "create an issue", "what issues are open", "close +issue #N", "comment on issue #N", "search issues for X" — even when the user does not say +"Gitea". Not pull requests -> `gitea-prs`. Not label or milestone definitions -> +`gitea-labels-milestones`. ``` +The retrofit kept the quoted-phrasing register and dropped the verb list, not the other way round. +Either register is admissible — what is banned is carrying both. Choose whichever routes better +for the skill in hand; here the quoted user phrasings do, because they are how people actually ask. + What came out, and why: | Removed | Why | @@ -132,7 +144,7 @@ What came out, and why: | `Composes gitea-labels-milestones for all label inference/resolution and milestone lookup` | A composition note. It changes no routing decision and belongs in `README.md`. | | The parenthetical `(create/edit/delete a label, create/close a milestone)` | Capability enumeration inside a boundary clause. The boundary needs the target, not its feature list. | | The `gitea-branches` / `git-branches` boundary | Dropped entirely. Neither was ever going to win an issue request, so the clause defended against nothing — an invented boundary costs characters and buys no routing accuracy. | -| `Do not use for pull requests (use gitea-prs)` prose form | Kept, but rewritten as `Not pull requests -> \`gitea-prs\`.` The rewrite buys characters and one uniform shape for the router — not safety. Both forms are parsed **and** target-checked, so a typo in the prose form dangles exactly as an arrow typo does. | +| `Do not use for pull requests (use gitea-prs)` prose form | Kept, but rewritten as `Not pull requests -> \`gitea-prs\`.` The rewrite buys characters, one uniform shape for the router, **and** a stricter check: an unresolved arrow target is a blocking ERROR, while an unresolved prose target is only a SUGGESTION unless another target in the same sentence resolves. The prose form does not dangle as loudly. | What stayed: one trigger clause, one capability clause, the indirect trigger (genuinely warranted here — people say "create an issue", not "create a Gitea issue"), and the boundary clauses. @@ -146,7 +158,9 @@ a skill that was never going to compete, not a second real one. **Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at -the start of the next fold into `gitea-labels- milestones`. `validate.sh` then reads the target as -`gitea-labels`, finds no such skill, and reports a dangling boundary target — the live finding on -`gitea-issues` today. Reflow the line so the whole name sits on one of them. The same applies to -any backticked skill or agent name in a description. +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports a dangling boundary target. This is not +hypothetical — it is how `gitea-labels-milestones` broke (issue #100). It is fixed: the corpus +carries no dangling target today, and the repo's test suite pins that set as empty, so a +reintroduction fails the suite rather than joining a backlog. Reflow the line so the whole name +sits on one of them. The same applies to any backticked skill or agent name in a description. diff --git a/plugins/kyberforge/.claude-plugin/plugin.json b/plugins/kyberforge/.claude-plugin/plugin.json index 071ecc2..b68cafa 100644 --- a/plugins/kyberforge/.claude-plugin/plugin.json +++ b/plugins/kyberforge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.6.0", + "version": "1.6.1", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/.github/plugin/plugin.json b/plugins/kyberforge/.github/plugin/plugin.json index 071ecc2..b68cafa 100644 --- a/plugins/kyberforge/.github/plugin/plugin.json +++ b/plugins/kyberforge/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.6.0", + "version": "1.6.1", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/agents/apm-orchestrate.agent.md b/plugins/kyberforge/agents/apm-orchestrate.agent.md index 3d135ad..7cd26ee 100644 --- a/plugins/kyberforge/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/agents/apm-orchestrate.agent.md @@ -1,7 +1,7 @@ --- name: apm-orchestrate -description: Orchestrates apm package/marketplace operations for other agents. Invoke when a caller needs a multi-step apm operation (scaffold a package, register it into a marketplace, compile/pack/publish, audit) coordinated across the apm-workflow skill with safety gates, session context, and structured results — especially fanning the same operation out across multiple packages in a monorepo. +description: Use when an agent caller needs a multi-step apm package or marketplace operation dispatched and safety-gated, including the same operation fanned out across a monorepo. Not apm binary or agent-runtime install -> apm-install. source_keys: - context7-microsoft-apm @@ -9,7 +9,7 @@ source_keys: disallowedTools: Edit, Write, NotebookEdit --- -You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. +You are the orchestrator for apm package/marketplace operations. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. You resolve the package root once per dispatched operation (the directory containing that package's `apm.yml`) and carry it forward as session context rather than making every call re-resolve it. @@ -19,26 +19,17 @@ You resolve the package root once per dispatched operation (the directory contai These are non-negotiable regardless of `confirm` or any skill-local override: - `apm publish` claims a version on a registry — treat it as irreversible. Refuse without explicit `confirm: true`; always dispatch with `--dry-run -v` first and surface that output to the caller before the real publish, even when `confirm: true` was given. -- Never guess the marketplace-add direction from context — resolve strictly from the operation name (`add-package` vs `add-marketplace`); see apm-workflow/SKILL.md Gotchas for why the two are easy to conflate. -- `apm.yml`'s `type:` field constrains what `.apm/` may contain — when scaffolding (`init-package`), set `type:` before any primitive content is added; do not defer it. +- Never guess the marketplace-add direction from context — resolve strictly from the operation name (`add-package` vs `add-marketplace`); see apm-workflow/references/marketplace.md Gotchas for why the two are easy to conflate. +- `type:` correctness is `apm-workflow`'s call — do not pre-set or second-guess it. - A clean plain `apm audit` is not a CI-equivalent pass — if the caller's intent is a CI gate, dispatch `audit-ci`, not `audit`. -- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/SKILL.md Gotchas for the underlying constraint. +- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/references/configure.md Gotchas for the underlying constraint (summarised in its SKILL.md Gotchas). - You are read-only against the working tree. Never create, edit, or delete a file — not an `apm.yml`, not a `.apm/` primitive, not compiled output, not a scratch note. `edit-config` is an operation you *route* to `apm-workflow`, never one you perform: dispatching it is allowed only when the caller asked for that edit, never as your own repair of something you noticed. -When invoked, you: -1. Parse the incoming workflow request (operation type, parameters, target package(s), context overrides) -2. Check safety gates: if the operation is `publish` and the request lacks explicit `confirm: true`, fail immediately with "requires explicit confirmation" -3. Route to `apm-workflow` with the resolved action (`configure`, `marketplace`, `install`, `compile`, `audit`) -4. Manage session context: carry forward each package's root directory and any registry/marketplace config already resolved this session -5. Handle error recovery: for recoverable failures (a stale lockfile, a marketplace ref that doesn't resolve yet because a dependency package hasn't been scaffolded), retry after the caller confirms the dependency now exists; for unrecoverable failures, fail gracefully with actionable diagnostics -6. When fanning an operation across multiple packages (e.g. `init-package` for every `plugins//` directory in a monorepo-hybrid conversion), dispatch independent packages in parallel when no shared state or ordering dependency exists between them; keep dispatch strictly sequential only for packages with a real dependency on another package's completion (e.g. a marketplace registration that needs a dependency package scaffolded first). Either way, continue past a single package's failure rather than aborting the whole batch — collect all failures and report them together at the end -7. Aggregate results and return structured JSON output suitable for agent chaining - ## Inputs - **operation:** string, one of: - - configure: init-package, edit-config (→ author/edit an existing package's `apm.yml` directly — adding a dependency, script, registries block, or removing a `marketplace.packages[]` entry; not a distinct `apm` CLI verb, just a manifest edit, optionally followed by `compile: pack` if it affects a published marketplace listing) - - marketplace: init-marketplace, check-marketplace, add-package (→ `apm marketplace package add` — register a local package into a marketplace being built), add-marketplace (→ `apm marketplace add` — register a marketplace as a consumer) + - configure: init-package, edit-config (→ author/edit an existing package's `apm.yml` directly — adding a dependency, script, registries block, or adding/removing a `marketplace.packages[]` entry, including the local-package registration `apm marketplace package add` cannot perform; not a distinct `apm` CLI verb, just a manifest edit, optionally followed by `compile: pack` if it affects a published marketplace listing) + - marketplace: init-marketplace, check-marketplace, add-package (→ `apm marketplace package add` — register a **remote** package reference (`owner/repo`, host URL, or full URL) into a marketplace being built; it rejects a local path, so a local package is not this operation — route it to `edit-config` instead), add-marketplace (→ `apm marketplace add` — register a marketplace as a consumer) - install: install (→ `apm install [PACKAGE_REF]` — resolve/fetch dependencies declared in `apm.yml` against `apm.lock.yaml`; no arguments re-resolves everything) - compile: compile, pack, publish, run-script - audit: audit, audit-ci @@ -50,7 +41,7 @@ When invoked, you: ## Process 1. Validate the request structure and check if `operation` is known -2. Check the request against the Hard rules above (publish confirmation, marketplace-add direction, `type:` ordering, audit-vs-audit-ci, registries precondition) — refuse outright on violation, independent of `confirm` +2. Check the request against the Hard rules above (publish confirmation, marketplace-add direction, `type:` delegation, audit-vs-audit-ci, registries precondition) — refuse outright on violation, independent of `confirm` 3. If `operation` is `publish`: require `confirm: true`, dispatch `--dry-run -v` first regardless, surface that output, else fail with structured "requires explicit confirmation" error 4. Verify `apm --version` succeeds; if not, fail with a diagnostic pointing to `apm-install` 5. Invoke `apm-workflow` via `Skill` with the resolved action, `package_root`, and parameters diff --git a/plugins/kyberforge/apm.yml b/plugins/kyberforge/apm.yml index b59a56e..fc1e122 100644 --- a/plugins/kyberforge/apm.yml +++ b/plugins/kyberforge/apm.yml @@ -1,5 +1,5 @@ name: kyberforge -version: 1.6.0 +version: 1.6.1 description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. author: name: Defame1297 diff --git a/plugins/kyberforge/skills/agent-audit/README.md b/plugins/kyberforge/skills/agent-audit/README.md index d25374f..3face54 100644 --- a/plugins/kyberforge/skills/agent-audit/README.md +++ b/plugins/kyberforge/skills/agent-audit/README.md @@ -57,8 +57,9 @@ Pass the path to either agent file as the argument. | `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` | 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/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions | +| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, the delegation FAIL, why agents take no body word gate, and what an agent body is for | | `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 | diff --git a/plugins/kyberforge/skills/agent-audit/SKILL.md b/plugins/kyberforge/skills/agent-audit/SKILL.md index 6153619..19abac3 100644 --- a/plugins/kyberforge/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/skills/agent-audit/SKILL.md @@ -20,7 +20,7 @@ metadata: - 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. +- At plugin/APM scope the agent is a single vendor-neutral file by design, so provider safety stops meaning Claude-Code-versus-Copilot field leakage there. - 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 — Deterministic checks @@ -30,14 +30,14 @@ Resolve all three paths against this skill's own directory so they work from a r ```bash bash scripts/validate.sh bash scripts/validate-provenance.sh -scripts/vale-wrap.sh [] +bash scripts/vale-wrap.sh [] ``` `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. 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. -`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. +`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass, including the silent exit 0 at project or user scope, where plugin-scope provenance does not apply. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a bad argument or a missing dependency, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason; never grade it as a clean pass. `validate.sh` uses the same 2 tier. `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: @@ -57,14 +57,14 @@ Read the agent file end to end, and at project/user scope its counterpart too. A ## Step 3 — Qualitative audit -Load a dimension's rubric before judging that dimension. +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body, delegation, comment-discipline | `references/body-and-delegation.md` | -Cite file and line number for every finding. +Each rubric is the reasoning behind its criteria, not a second copy of them. Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/skills/agent-audit/references/README.md b/plugins/kyberforge/skills/agent-audit/references/README.md index 58bda01..6ae112e 100644 --- a/plugins/kyberforge/skills/agent-audit/references/README.md +++ b/plugins/kyberforge/skills/agent-audit/references/README.md @@ -10,8 +10,9 @@ Additional documentation agents load on demand. | File | Purpose | |------|---------| -| `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. | +| `finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading. | +| `description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions. | +| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, 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. | diff --git a/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md b/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md index ef9e598..325bed6 100644 --- a/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md +++ b/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md @@ -98,24 +98,8 @@ to a shipped file. At plugin/APM scope the stakes are higher than tidiness: `apm frontmatter verbatim to every target, `` is not valid YAML, and `validate.sh` FAILs a frontmatter block that still contains one. -## Auditing guidance +## Where the criteria live -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 +Every FAIL and SUGGESTION criterion for these dimensions is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the body, delegation or comment-discipline dimension in play. diff --git a/plugins/kyberforge/skills/agent-audit/references/description-quality.md b/plugins/kyberforge/skills/agent-audit/references/description-quality.md index d3863af..eac9c27 100644 --- a/plugins/kyberforge/skills/agent-audit/references/description-quality.md +++ b/plugins/kyberforge/skills/agent-audit/references/description-quality.md @@ -82,49 +82,8 @@ description: > dispatched and safety-gated. Not conversational git help -> git-workflow. ``` -## Auditing guidance +## Where the criteria live -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. +Every FAIL and SUGGESTION criterion for this dimension is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the description dimension in play. diff --git a/plugins/kyberforge/skills/agent-audit/references/field-inventory.md b/plugins/kyberforge/skills/agent-audit/references/field-inventory.md index 9f0a083..85b27bd 100644 --- a/plugins/kyberforge/skills/agent-audit/references/field-inventory.md +++ b/plugins/kyberforge/skills/agent-audit/references/field-inventory.md @@ -38,9 +38,9 @@ whose vocabulary differs per harness — Claude Code names its own tools, Copilo other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent -there, never that the wrong capability is granted. Claude Code honours it for plugin subagents — -`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents -silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. +there, never that the wrong capability is granted. Claude Code honours it for plugin subagents: its +plugin agent-definition reference names the fields plugin agents silently ignore (`hooks`, +`mcpServers`, `permissionMode`), and `disallowedTools` is not among them. `disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`. diff --git a/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md new file mode 100644 index 0000000..5cd8509 --- /dev/null +++ b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md @@ -0,0 +1,98 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +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, so 400 is the only hard limit + there is — 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`. +- **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. +- **`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 of + `references/description-quality.md`. + +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. + +**An unresolved boundary target is not graded here.** `validate.sh` resolves boundary targets for +agent files at both scopes and tiers the verdict itself — route notation (`/name`, an arrow form) +is an ERROR, the bare prose form a SUGGESTION unless a second target in the same sentence resolves. +Step 1 has already filed it under `### Structure` at that tier. Take the script's verdict rather +than re-resolving the name by hand, and do not re-grade it under description: a hand-walk over a +different universe can contradict the script, and re-grading puts one target in the report twice. +What is left to judgment 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. + +## body, delegation and comment-discipline — `references/body-and-delegation.md` + +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 + +**Never report an agent body as too long on a word count.** ADR-0020 gates a skill body at +600/900 words and deliberately gates an agent body at nothing, because an agent body *becomes* the +system prompt of a fresh context rather than competing with a live conversation. No number exists +to cite. The one length signal that applies is the Copilot runtime's 30,000-character body limit, +which `validate.sh` already reports as a SUGGESTION. Length is judged through the delegation FAIL +above instead. diff --git a/plugins/kyberforge/skills/agent-audit/references/sources.md b/plugins/kyberforge/skills/agent-audit/references/sources.md index 748c4f8..7fcca6f 100644 --- a/plugins/kyberforge/skills/agent-audit/references/sources.md +++ b/plugins/kyberforge/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, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.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/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.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, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.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/skills/agent-audit/references/validation-scripts.md b/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md index d6bc4ac..55e949c 100644 --- a/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md +++ b/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md @@ -34,8 +34,14 @@ first of these: `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. +when the package has no provenance data. Check the exit code before you believe the silence: + +- **0** — a pass, not a skip you need to investigate. Both silent cases above land here. +- **1** — real findings, on stdout with Why and Fix. +- **2** — the check never ran. A missing, doubled, non-file or wrongly-named argument, an + undecodable `apm.yml`, or an absent `python3`, each with a diagnostic on stderr and no findings + at all. Report the `### Provenance` dimension as unverified and quote the reason. An exit 2 is + never a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. ## Manual fallback diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh index 20052bd..1dcc003 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh @@ -16,15 +16,48 @@ Arguments: Exit codes: 0 All checks passed (or nothing to validate, or not plugin scope) 1 One or more checks failed - 2 Script error (unrecognized file extension — expected .md or .agent.md) + 2 Usage error, or the argument is not an agent file this script can read + +An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a +non-zero exit as findings, so a usage error leaving exit 1 with nothing on +stdout was indistinguishable from a clean-but-failing run. Environment and +argument problems exit 2; only real findings exit 1. + +Exit 2 and the silent exit 0 answer two DIFFERENT questions, and neither may +be spelled with the other's code: + + exit 2 the argument is not something this script can audit at all — it is + missing, doubled, not a file, or not named .md / .agent.md. Decided + before the scope walk-up runs, from the argument alone. + exit 0 the argument IS a readable agent file, and the scope walk-up found + no type:-bearing apm.yml above it before hitting the \$HOME, .git or + filesystem-root boundary. That is a real verdict about a real file — + "this agent is user or project scope, so plugin-scope provenance + does not apply to it" — not a rejected input. + +scripts/check-scope-walkup-sync.sh's fixture 6 pins the second: a real agent +file under a \$HOME with a type-bearing apm.yml ABOVE it must exit 0 with empty +output. Widening exit 2 to cover "the walk-up found no package" would break +that fixture AND would be wrong on its own terms, because new-agent.sh happily +scaffolds exactly that layout. Checks performed: 0 source_keys present in agent pair but sources.md absent 1 FILL IN: placeholders in sources.md 2 source_keys in agent files → slug exists in sources.md - 3 Contributing files listed in sources.md exist on disk (plugin-root relative) + 3 Contributing files listed in sources.md exist on disk (plugin-root + relative). An explicit '(none)' skips silently; a Contributing files block + this parser cannot read is reported as an INFO saying checks 3 and 4 did + not run, never skipped silently. 4 Contributing files back-reference the parent slug in their source_keys 5 Research doc field present and not placeholder + +This script has no counterpart to skill-audit's checks 6, 7 and 8 (Research +doc field / upstream forward / upstream reverse are numbered 6, 7, 8 there and +5 here): an agent at plugin scope is a single file with a plugin-root +sources.md, so there is no references/ tree to walk and no upstream research +source index to cross-check. parse_status() and the sources.md-basename gate +that those checks need exist only in the skill-audit copy. EOF } @@ -33,26 +66,131 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then exit 0 fi +# Usage and environment problems exit 2, findings exit 1. See the usage text +# above for why the two must not share a code, and for why "not plugin scope" +# is neither of them. This is a deliberate divergence from validate.sh, which +# has no 2 tier for content: validate.sh always prints PASS lines, so a usage +# error there is visibly not a findings report. This script prints NOTHING on a +# clean run, so exit 1 plus empty stdout was the only signal a caller got +# either way. if [[ $# -lt 1 ]]; then echo "Error: agent-file is required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi +# Extra positional arguments were silently dropped, so a typo'd flag or a second +# path looked like it had been honoured. +if [[ $# -gt 1 ]]; then + echo "Error: expected exactly one argument, got $#: $*" >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + +# python3 is a HARD dependency. Without this preflight a missing interpreter +# produced 'line NN: python3: command not found' and exit 127 — an exit code no +# caller maps to anything, from a message that names this script's line number +# rather than the missing dependency. +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 provenance checks entirely 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 2 +fi + +# A path that does not exist, or exists but is not a regular file, used to reach +# the Python body, get os.path.dirname()'d into some ancestor directory and then +# either report a silent exit 0 (no package above it) or — worse — audit a +# DIFFERENT agent's package while naming the typo'd path. A typo'd target was +# indistinguishable from a clean agent. vale-wrap.sh hard-errors on a +# nonexistent path for exactly this reason. +# +# This is decided from the argument alone, before any walk-up runs, so it cannot +# collide with the not-plugin-scope exit 0: that verdict is only ever reached by +# a file that got past here. +if [[ ! -e "$1" ]]; then + echo "Error: no such file: $1" >&2 + echo " Why: a nonexistent target would otherwise report a silent pass." >&2 + echo " Fix: pass the path of the agent file to validate." >&2 + exit 2 +fi + +if [[ ! -f "$1" ]]; then + echo "Error: not a regular file: $1" >&2 + echo " Why: this script audits one agent file, not a directory of them, and reporting a directory as a pass hides the wrong-target mistake." >&2 + echo " Fix: pass the agent file itself — .apm/agents/.agent.md — not its parent directory." >&2 + exit 2 +fi + +# The extension check used to live inside the Python body. It stays exit 2 and +# keeps its wording; it moves up here so that every "this argument is not +# auditable" verdict is reached in one place, before the interpreter starts and +# before the scope walk-up can turn a bad argument into a silent exit 0. +case "$1" in + *.agent.md | *.md) ;; + *) + echo "Error: unrecognized extension '$(basename "$1")' — expected .md or .agent.md" >&2 + exit 2 + ;; +esac + python3 -u - "$1" <<'PYTHON' import sys import os import re +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and every finding this script prints contains an em dash. +# Pinning only the reads moved the crash from the read to the write — a +# UnicodeEncodeError inside print_findings(), which loses the whole report +# after all the checks have already run. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) -fname = os.path.basename(agent_file) agent_dir = os.path.dirname(agent_file) -# --- Sanity-check extension (single vendor-neutral .agent.md file at plugin/APM scope) --- -if not (fname.endswith('.agent.md') or fname.endswith('.md')): - print(f"Error: unrecognized extension '{fname}' — expected .md or .agent.md", file=sys.stderr) - sys.exit(2) +# --- Input ---------------------------------------------------------------- +# Ported from the skill-audit copy, where the same two problems were already +# fixed. +# +# read_text() pins UTF-8 explicitly instead of inheriting +# locale.getpreferredencoding(), which is ASCII under LC_ALL=C — an ordinary em +# dash in an agent file or in sources.md then aborted the run with a bare +# UnicodeDecodeError traceback, or, at the one call site that wrapped its read +# in `except Exception: return []`, reported the unreadable file as having no +# source_keys and therefore as clean. A file that genuinely is not UTF-8 still +# fails; it just says which file and why. +# +# strip_bom() runs on every read because a leading BOM defeats +# parse_frontmatter()'s `^---` anchor, which silently disabled check 2 on a +# BOM-prefixed agent file: no frontmatter parsed means no source_keys parsed +# means nothing to validate. + + +class EncodingError(Exception): + pass + + +def strip_bom(text): + return text[1:] if text.startswith(u'\ufeff') else text + + +def read_text(path): + """File contents as text, UTF-8 and BOM-free, with a diagnostic instead of a traceback.""" + try: + with open(path, encoding='utf-8') as fh: + return strip_bom(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)) # Matches a top-level `type:` line whose value is exactly one of the four # package content types — identical to validate.sh's APM_TYPE_RE. Group 1's @@ -67,15 +205,31 @@ TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\1(?: # keep walking. Stop at a $HOME boundary, a .git boundary, or the filesystem # root: none of these is plugin/APM scope, so this script has nothing to # check there. +# +# Returning None here means NOT PLUGIN SCOPE, which is a verdict, not an error: +# the caller exits 0 silently, and scripts/check-scope-walkup-sync.sh fixture 6 +# pins that. It is deliberately NOT folded into the exit-2 tier above. def find_plugin_root(start_dir): home = os.path.expanduser('~') current = os.path.abspath(start_dir) while True: apm_yml = os.path.join(current, 'apm.yml') if os.path.isfile(apm_yml): - with open(apm_yml) as f: - if any(TYPE_RE.match(line) for line in f): - return current + # An apm.yml is a manifest this script must be able to READ to + # classify scope at all. Under LC_ALL=C the old bare open() decoded + # as ASCII, so a manifest with an accented author name raised + # UnicodeDecodeError mid-walk and killed the run with a traceback. + # It is an environment problem, not a finding, so it exits 2 rather + # than being swallowed into a silent "no package here". + try: + content = read_text(apm_yml) + except EncodingError as exc: + print( + "Error: %s is %s" % (apm_yml, exc), + file=sys.stderr) + sys.exit(2) + if any(TYPE_RE.match(line) for line in content.splitlines()): + return current # $HOME is a non-plugin-scope boundary — checked before the .git test # below (mirrors validate.sh's detect_scope ordering), so a # dotfiles-managed $HOME (yadm, chezmoi bare-repo, etc.) can't shadow @@ -101,7 +255,14 @@ if plugin_root is None: sources_md_path = os.path.join(plugin_root, 'sources.md') # --- Helpers --- -PLACEHOLDER_RE = re.compile(r'(? "references/a.md" + return re.sub(r'\s*\(.*$', '', entry).strip() + + # Inline form: value on the same line, comma-separated, no notes. cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE) + if cf_m: + value = cf_m.group(1).strip() + if value.startswith("(none"): + return [] + return [p for p in (strip_note(x) for x in value.split(",")) + if p] or None + + # Bullet form: heading on its own line, one file per following bullet. + cf_m = re.search(r'^\*\*Contributing files:\*\*\s*$', block, re.MULTILINE) if not cf_m: return None - return cf_m.group(1).strip() + files = [] + for line in block[cf_m.end():].splitlines(): + line = line.strip() + if not line: + if files: + break + continue + if not line.startswith("- "): + break + entry = line[2:].strip() + if entry.startswith("(none"): + return [] + entry = strip_note(entry) + if entry: + files.append(entry) + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== -def parse_research_doc(content, slug): +def parse_research_docs(content, slug): + """Every Research doc value under a given slug H2, in document order. + + The caller uses the first and reports the rest. Returning only the first — + what this did before — meant a second '- **Research doc:**' line in one + entry was silently ignored, so an author who added a doc rather than + replacing one got check 5 run against the old value and no hint that the + new one was never looked at. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL ) m = pattern.search(content) if not m: - return None + return [] block = m.group(1) - rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE) - if not rd_m: - return None - return rd_m.group(1).strip() + return [v.strip() for v in + re.findall(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)] findings = [] has_fail = False +# A finding identical in every field is the same finding, and the same file is +# now reached by more than one check — the agent file is read once for its own +# source_keys and again as a contributing file, so an unreadable one would +# otherwise be reported twice with the same words. Distinct findings about the +# same file still both appear. +def _record(entry): + if entry not in findings: + findings.append(entry) + def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix)) + _record(("FAIL", desc, fpath, why, fix, None)) + +# INFO does not set has_fail and does not change the exit code. It is for a +# check that could not RUN — an unverified entry, not a broken one — and it +# exists so that "did not run" is never spelled the same way as "passed". +def emit_info(desc, fpath, note): + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): - for kind, desc, fpath, why, fix in findings: - print(f"FAIL {desc} — {fpath}") - print(f" Why: {why}") - print(f" Fix: {fix}") - print() + for entry in findings: + kind = entry[0] + desc = entry[1] + fpath = entry[2] + why = entry[3] + fix = entry[4] + note = entry[5] + if kind == "FAIL": + print(f"FAIL {desc} — {fpath}") + print(f" Why: {why}") + print(f" Fix: {fix}") + print() + else: + print(f"INFO {desc} — {fpath}") + print(f" Note: {note}") + print() + +def emit_unreadable(rel, exc): + """Report a file this script cannot decode. Never a silent skip.""" + emit_fail( + f"File is {exc}", + rel, + f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — " + f"cannot be read. This used to be swallowed by a bare 'except Exception: return []', " + f"which reported the unreadable file as having no source_keys and therefore as clean.", + f"Re-save '{rel}' as UTF-8." + ) # --- Collect source_keys from agent pair --- -def get_source_keys_from_file(fpath): +def get_source_keys_from_file(fpath, rel): if not os.path.isfile(fpath): return [] try: - with open(fpath) as f: - content = f.read() - except Exception: + content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) return [] fm, _ = parse_frontmatter(content) return parse_source_keys(fm) # Plugin/APM scope is a single vendor-neutral file — no counterpart to merge. -given_keys = get_source_keys_from_file(agent_file) +rel_given = os.path.relpath(agent_file, plugin_root) +given_keys = get_source_keys_from_file(agent_file, rel_given) all_source_keys = given_keys sources_md_exists = os.path.isfile(sources_md_path) -# Early exit: nothing to validate +# Early exit: nothing to validate. The read above can itself raise a finding — +# an unreadable agent file — so print before leaving; the clean case still +# prints nothing and exits 0. if not all_source_keys and not sources_md_exists: - sys.exit(0) + print_findings() + sys.exit(1 if has_fail else 0) sources_content = None sources_slugs = set() if sources_md_exists: - with open(sources_md_path) as f: - sources_content = f.read() + try: + sources_content = read_text(sources_md_path) + except EncodingError as exc: + emit_unreadable("sources.md", exc) + print_findings() + sys.exit(1) sources_slugs = set(parse_h2_slugs(sources_content)) # --- Check 0: source_keys present but sources.md absent --- if not sources_md_exists and all_source_keys: - rel_given = os.path.relpath(agent_file, plugin_root) emit_fail( "source_keys declared but sources.md is absent", rel_given, @@ -240,11 +531,49 @@ for fpath, keys in [(agent_file, given_keys)]: ) # --- Checks 3, 4, 5: Per-slug checks in sources.md --- -for slug in parse_h2_slugs(sources_content): - # Check 3: Contributing files exist (paths relative to plugin root) - cf_value = parse_contributing_files(sources_content, slug) - if cf_value and not cf_value.startswith("(none"): - cf_files = [p.strip() for p in cf_value.split(",") if p.strip()] + +# Every per-slug parser below — parse_contributing_files, parse_research_docs — +# locates its block with pattern.search(), so a slug written twice resolves to +# the FIRST block every time. Iterating the raw heading list therefore checked +# the first block's fields twice and the second block's never: a duplicated slug +# is half-validated, and looked fully validated. The duplicate is announced and +# the repeat visit dropped. +all_slugs = parse_h2_slugs(sources_content) +unique_slugs = [] +for _slug in all_slugs: + if _slug in unique_slugs: + continue + unique_slugs.append(_slug) + _count = all_slugs.count(_slug) + if _count > 1: + emit_info( + f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked", + f"sources.md (## {_slug})", + f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the " + f"second and later blocks' Contributing files and Research doc are never validated — " + f"checks 3, 4 and 5 did not run for them. " + f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys." + ) + +for slug in unique_slugs: + # Checks 3 and 4: Contributing files exist (paths relative to plugin root), + # and back-reference the slug. `[]` and None are NOT the same answer here. + # `[]` is the author writing "(none)" — there is nothing to check and the + # skip is correct. None is a Contributing-files block this parser cannot + # read, and skipping THAT silently disables both checks on the one entry + # least likely to be right, which is the failure mode + # parse_contributing_files' own docstring warns about. Say so out loud. + cf_files = parse_contributing_files(sources_content, slug) + if cf_files is None: + emit_info( + f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed", + f"sources.md (## {slug})", + f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. " + f"Checks 3 and 4 did not run for this slug, so nothing verified that its contributing files exist or name it back. " + f"Write the value as '- **Contributing files:** ', or as a '**Contributing files:**' heading followed by '- ' bullets — " + f"or record '(none)' if this source contributed no files." + ) + elif cf_files: for cf_rel in cf_files: cf_abs = os.path.join(plugin_root, cf_rel) if not os.path.isfile(cf_abs): @@ -256,8 +585,11 @@ for slug in parse_h2_slugs(sources_content): ) else: # Check 4: Bidirectional — file should list slug in its source_keys - with open(cf_abs) as f: - cf_content = f.read() + try: + cf_content = read_text(cf_abs) + except EncodingError as exc: + emit_unreadable(cf_rel, exc) + continue cf_fm, _ = parse_frontmatter(cf_content) cf_keys = parse_source_keys(cf_fm) if slug not in cf_keys: @@ -269,7 +601,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 5: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; check 5 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( "Research doc field missing", diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh index fc0f711..738ab4e 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh @@ -69,6 +69,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] @@ -253,9 +271,27 @@ def _collect_package(pkg_dir, names): 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()) + # A directory is a skill only if it HOLDS a SKILL.md. An empty + # leftover — a deleted skill whose directory survived, a scaffolding + # stub, an editor's stray mkdir — is untracked by git, so it exists + # on the machine that made it and nowhere else. Counting it made a + # boundary target resolve locally and dangle in a fresh clone: the + # same install-dependence the deployed-tree rule above exists to + # remove, arriving through a different door. + if os.path.isfile(os.path.join(path, 'SKILL.md')): + 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)): + # The same rule one directory over, which until now had no + # counterpart here at all: the skills branch above tests for a + # SKILL.md, the agents branch took every glob hit on trust. A + # DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not + # tell the two apart, so a leftover of that shape resolved a routing + # target on the machine holding it and dangled everywhere else — + # identical install-dependence, arriving through the one door + # nobody guarded. + if not os.path.isfile(path): + continue base = os.path.basename(path) if base.endswith('.agent.md'): base = base[:-len('.agent.md')] @@ -446,8 +482,17 @@ def known_targets(start_dir): # 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`. +# `Not -> `. The example that motivated it is gone: +# diagnose's process chain "fix -> regression-test", which without the +# gate read as a route to a non-existent `regression-test` skill, was cut +# when issue #99 retrofitted that description. So the gate is currently +# UNEXERCISED — gating and not gating produce the same verdict corpus-wide. +# Keep it anyway. It is a false-positive guard against prose no one has +# written yet, and any new process chain re-arms it. Unexercised is not the +# same as unnecessary, and the branch it guards is still load-bearing: the +# bare-arrow rule is the sole extractor for three real targets in +# kyberforge's audit skills (agent-audit -> agent-author, agent-audit -> +# skill-audit, skill-audit -> skill-author), all written unbackticked. # * 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 @@ -525,6 +570,66 @@ def known_targets(start_dir): # ambiguity to resolve, and an author who wants a route checked unconditionally # has two ways to say so. # +# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise +# above rather than a widening of it. Until the sweeps existed, notation was +# only ever seen as the OBJECT OF A ROUTE VERB (`use +# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in +# between. Every one of these therefore exited 0 in total silence — no ERROR, no +# SUGGESTION, not even the target's name: +# Do not use for Y — /no-such-skill instead. +# Do not use for Y; /no-such-skill handles that. +# Do not use for Y (/no-such-skill covers it). +# Do not use for Y — that is /no-such-skill's job. +# Do not use for Y — defer to /no-such-skill. +# Do not use for Y — /no-such-skill. +# Do not use for Y; -> no-such-skill covers it. +# For W, /no-such-skill is the right entry point. +# The target was never EXTRACTED, so the notation-first rule in _add() had +# nothing to apply itself to and the "always blocks" promise was false for the +# ordinary way an author writes the thing. The SUGGESTION tier made it worse +# than a gap: its printed remedy tells the author to "write it as `/name` or +# `-> name` and it will be checked properly", and taking that advice turned a +# visible SUGGESTION into silence — the gate teaching the one edit that blinds +# it. +# +# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point. +# `/name` is Claude Code's invocation syntax and nothing else — no English +# sentence contains one by accident — so the ADR-0020 amendment and +# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So +# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it +# on BOUNDARY_MARKER made that promise false for the last sentence of +# Do not use for Z — use /real-skill instead. +# For W, /no-such-skill is the right entry point. +# which exited 0 in total silence: the boundary clause is one sentence up, so +# the sweep never looked at the sentence carrying the broken route. Extraction is +# per-sentence by design (corroboration is scoped to one sentence), which is +# exactly what made the gap invisible. +# +# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep. +# Neither form is unambiguous: `-> name` is also how a process chain is written +# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a +# file and a skill are all cited. Ungating either would fire on prose that +# carries no routing intent at all — the false-positive class this whole +# extractor is tuned against. +# +# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`, +# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or +# `https://x/y`, not a route. A sentence's closing `.` is not followed by a +# non-space, so `— /no-such-skill.` still counts. +# +# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at +# all here: it holds after a hyphen, so when the trailing lookahead rejected the +# full segment the engine simply backtracked to a shorter hyphen-terminated +# prefix and reported THAT as a route. Every one of these was a hard blocking +# ERROR naming a skill nobody had written: +# the config lives at /opt-tools/bin/thing. -> 'opt' +# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs' +# the file /no-such-skill.md documents it. -> 'no-such' +# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is +# rejected as the path it is. MARKED_TARGET carries the same guard: it had no +# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of +# the two errors above through the route-verb path rather than the sweep. +# # 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. @@ -535,7 +640,8 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY +# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see +# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse +# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route +# verb or an arrow, and it may match a namespaced or path-adjacent token in +# positions this free-standing sweep must refuse. +# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is +# what made `Do not use for Y; -> no-such-skill covers it.` invisible: +# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the +# check. Dropping that prefix costs the one false positive the bare-arrow bullet +# above names — a process chain ending in a hyphenated word, `Instead, reproduce +# -> minimise -> regression-test.` — and costs it only in a sentence that already +# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same +# chain written `Do not use for X — reproduce -> regression-test.` was already a +# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the +# arrow, not whether prose can. An author who means the chain and not a route +# writes it in its own sentence, where neither pattern looks. +NOTATION_SLASH = re.compile( + r"(?|→)\s*(%s)\b" % NAME_HYPH, re.I) +# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT +# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a +# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, +# `.vale.ini` — was invisible to both patterns below, and the two resulting +# failures were different sizes (issue #110): +# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep +# still extracted the target, so the route was checked, but the gate +# reported "no boundary clause" on a clause that was present and working. +# Three authors in two retrofit waves reworded a correct clause to satisfy +# the regex, one of them stripping the very filename that discriminates the +# skill from its neighbour. +# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only +# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill` +# produced no target, no dangling report and no missing-clause SUGGESTION. +# Silence, not noise — the worse of the two failure modes. +# A dot inside a filename is followed by a non-space; a sentence-ending dot is +# followed by whitespace or by end of string. So the class admits a `.` only +# when the next character is not whitespace, which crosses `AGENTS.md` and +# still stops at a real sentence end. +CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))" +ARROW_BOUNDARY = re.compile( + r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, 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) +BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, 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" @@ -573,9 +720,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) # 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. +# re.I here too, and NOT as a tidy-up: this was the one pattern in the file +# built without it, contradicting the uniformity note on CONT_*/ARROW_* above. +# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is +# where an abbreviation most often lands — matched none of the lookbehinds, so +# the clause split at the abbreviation, the corroborating target was stranded on +# the far side of the cut, and a genuinely dangling target silently demoted from +# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described +# directly above, still live for exactly the capitalised half of the input. SENTENCE_SPLIT = re.compile( u'(? name` reached this function with + strict=True from its two call sites, but `/name` did not, so it fell to + _terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The + target then reached unresolved_targets() unblockable — and, before the + companion fix there, unreported as well. `... use /no-such-skill + afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an + author who wants a route checked unconditionally. + """ if not name: return + notation = _notation(text, start, arrow) + if strict is None and notation: + strict = True out.append((name, _terminal(text, end) if strict is None else strict, - _notation(text, start, arrow))) + notation)) def _scan(text, route_re, cont_re, out): @@ -678,7 +848,19 @@ def _extract_sentence(sentence): for match in ARROW_BOUNDARY.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) + # `/name` wherever it sits, in ANY sentence — not only where a route verb or + # an arrow happens to precede it, and NOT only inside a boundary sentence. + # See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings + # this recovers and for why silence was the failure mode. The sweep takes no + # follower test: _add() reads the notation first and marks it. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) if boundary: + # The arrow and backtick forms are ambiguous in ordinary prose, so they + # stay scoped to a sentence that carries a boundary marker. + for match in NOTATION_ARROW.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) for match in BACKTICK.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1)) return out @@ -697,6 +879,85 @@ def boundary_targets(description): return sorted({name for name, _, _ in _extract(description)}) +def _arrow_targets(description): + """Names extracted from ARROW notation specifically. + + Kept apart from boundary_targets() because the arrow form is the one shape + that ALWAYS names a target: ADR-0020's `Not -> `. A clause + written that way from which nothing could be extracted is a parse failure + that deserves its own message, and telling it apart needs the arrow targets + alone rather than every target in the description. + """ + out = [] + for sentence in SENTENCE_SPLIT.split(description): + for match in ARROW_MARKED.finditer(sentence): + name, _, _ = _first(match) + if name: + out.append(name) + for match in ARROW_BOUNDARY.finditer(sentence): + out.append(match.group(1)) + return out + + +def boundary_clause_status(description): + """'absent', 'unparsed' or 'present' — three outcomes, not two. + + Issue #110's standing request: the gate must distinguish "no boundary + clause" from "boundary clause I could not parse". Reporting the first for + the second sends the author hunting for a problem that is not there, and + three of them reworded a correct clause to satisfy a regex instead. + + 'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was + detected and NO target came out of it. The arrow form always names one, so + zero targets means the name is written in a shape the extractor cannot see + — a single-word bare target (`Not X -> forge`, which has to be written + `` `forge` `` or `/forge`) is the live example, since single-word names are + deliberately not matchable bare. + + A PROSE clause yielding no target is NOT reported: "Do not use for anything + else" is a complete and legitimate boundary clause that names nowhere to go. + """ + if BOUNDARY_ARROW.search(description) and not _arrow_targets(description): + return 'unparsed' + if has_boundary_clause(description): + return 'present' + return 'absent' + + +def multi_target_arrow_clauses(description): + """[(first, second)] for arrow clauses naming more than one target. + + Issue #107: only the FIRST target after an arrow is resolved. The + conjunction continuation (CONT_*) is wired to the prose route verbs and + never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither + resolved nor reported, and then printed "1 of 1 boundary target(s) resolve" + on a clause naming two — a gate under-reporting its own coverage, which is + the one failure mode ADR-0020 says a gate must not have. + + The clause is REJECTED rather than the arrow scan extended. Extending it + would widen the resolver's deliberately conservative false-positive tuning + across every arrow in the corpus; rejecting costs nothing and makes the + one-arrow-per-target convention — already what every retrofitted gitea + skill does in practice — explicit instead of folkloric. The caller emits a + SUGGESTION telling the author to split. + """ + hits = [] + for sentence in SENTENCE_SPLIT.split(description): + matches = (list(ARROW_MARKED.finditer(sentence)) + + list(ARROW_BOUNDARY.finditer(sentence))) + for match in matches: + first, _, _ = _first(match) + if not first: + continue + cont = CONT_ANY.match(sentence, match.end()) + if not cont: + continue + second, _, _ = _first(cont) + if second: + hits.append((first, second)) + return hits + + def unresolved_targets(description, known): """Targets resolving to nothing, split into (blocking, reported). @@ -713,6 +974,17 @@ def unresolved_targets(description, known): 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. + + A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed + whitelist of maybe eighty words, so the follower rule says "this token is + outside a list I keep" and not "this is prose" — and the old `continue` + turned that into invisibility at every tier. The gate then failed OPEN on + its own unfamiliarity: any target followed by a word nobody thought to + enumerate was neither blocked nor mentioned, so the check that did not run + said nothing about not running. The follower rule may withdraw the power to + BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note + above is the argument for it — but it may not withdraw visibility, which is + the same rule the corroboration tier already follows. """ blocking, reported = set(), set() for sentence in SENTENCE_SPLIT.split(description): @@ -721,7 +993,10 @@ def unresolved_targets(description, known): 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: + if key in known: + continue + if not may_dangle: + reported.add(name) continue if notation or (resolved - {key}): blocking.add(name) @@ -801,6 +1076,47 @@ def description_value(fm_text): return re.sub(r'\s+', ' ', value).strip() +def hand_invoked(fm_text): + """True when the frontmatter marks this file as reached only by hand. + + `disable-model-invocation: true` removes a skill from the model-visible + listing entirely — it is not preloaded, and the Skill tool refuses to call + it — so its description is never matched against user intent. ADR-0020 and + skill-author's contract give such a skill ONE plain human-facing sentence: + no trigger list, no boundary clause. No validator knew the field existed + (issue #108), so the boundary-clause SUGGESTION fired on exactly the shape + the contract mandates, and its remedy — "add a boundary clause so the router + knows where NOT to send this skill" — was addressed to a router that cannot + see the skill at all. An author who followed the advice made the file worse. + + Only the ROUTING rules are lifted. The body word budget still applies: the + body is loaded on invocation like any other, and competes with the caller's + live conversation the same way. So does the 400-character description FAIL — + a hand-invoked description is not preloaded, but it is still the one line + the user reads when choosing from the `/` menu, and the ceiling is the + outlier stop rather than the style target. + + A parse failure returns False rather than raising. This is a MODIFIER on + other checks, not a check of its own: the frontmatter's validity is decided, + and failed, by description_value() on the same text, and raising a second + exception here would report one broken file twice with two different + diagnoses. + """ + try: + data = yaml.safe_load(fm_text) + except Exception: + return False + if not isinstance(data, dict): + return False + value = data.get('disable-model-invocation') + if isinstance(value, str): + # PyYAML already resolves the unquoted YAML 1.1 booleans, so this only + # catches a QUOTED "true" — which a host reads as truthy and which no + # gate should treat as opting back in to the routing rules. + return value.strip().lower() in ('true', 'yes', 'on') + return value is True + + # --- 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 @@ -915,7 +1231,15 @@ def missing_reference_pointers(body, skill_dir): end = masked.find('\n', match.end()) if end < 0: end = len(masked) - if REFERENCE_PAST.search(masked[start:end]): + # The pointer's OWN SPAN is excised before the sweep. Run over the + # whole line, the past-tense test matched the very path it was judging, + # so a file exempted itself by its NAME: `references/deprecated-api.md`, + # `references/removed-flags.md` and `references/gone.md` produced no + # ERROR at all, while `references/missing.md` — an identical break — + # errored. The exemption is about what the SENTENCE says about the + # pointer, never about what the pointer is called. + line = masked[start:match.start()] + masked[match.end():end] + if REFERENCE_PAST.search(line): continue if REFERENCE_QUALIFIER.search(masked[start:match.start()]): continue @@ -968,8 +1292,14 @@ def agent_description(fm, local_fname): f"not run — {local_fname}") return None -def check_description_budget(value, local_fname): - """ADR-0020 description gates — identical for every scope.""" +def check_description_budget(value, local_fname, by_hand=False): + """ADR-0020 description gates — identical for every scope. + + `by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent + carrying `disable-model-invocation: true` is absent from the model-visible + listing, so the 250-character SUGGESTION — a routing-quality budget — has + no listing to apply to. The 400-character ceiling is unaffected. + """ if not value: return dlen = len(value) @@ -979,13 +1309,13 @@ def check_description_budget(value, local_fname): 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: + elif dlen > DESC_SUGGEST_CHARS and not by_hand: 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): +def check_boundary(value, fpath, local_fname, by_hand=False): """ADR-0020 boundary clause + resolvable boundary targets. agent-author's SKILL.md states that an agent's boundary targets must @@ -1002,10 +1332,31 @@ def check_boundary(value, fpath, local_fname): # 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): + # + # THREE outcomes, not two: "no boundary clause" and "boundary clause I could + # not parse" are different findings (issue #110). And a hand-invoked agent is + # exempt from the clause altogether (issue #108) — the boundary-target + # resolution below still runs, because a target it DOES name should still + # resolve. + status = boundary_clause_status(value) if not by_hand else 'present' + if status == 'absent': 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}") + elif status == 'unparsed': + suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no " + f"target could be read, so the dangling-target check did not run on it — " + f"the clause is PRESENT and unparsed, not missing. Most often the target " + f"is a single word, which is deliberately not matchable bare: write it as " + f"`name` or /name — {local_fname}") + if not by_hand: + # One arrow, one target: a second name after the same arrow is resolved + # by nothing and reported by nothing (issue #107). + for first, second in multi_target_arrow_clauses(value): + suggest(f"an arrow boundary clause names more than one target ('{first}', then " + f"'{second}') and only the first is resolved — the second is checked by " + f"nothing. Split it into one arrow per target: \"Not X -> {first}. " + f"Not Y -> {second}.\" — {local_fname}") targets = boundary_targets(value) if not targets: return @@ -1240,8 +1591,9 @@ def check_apm_agent_file(fpath, allowlist, stem): else: 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) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body — required, non-empty, no placeholder; same Copilot truncation risk # applies since this file compiles verbatim into a real Copilot file downstream. @@ -1336,8 +1688,9 @@ def check_file(fpath, file_provider): else: 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) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body if not body.strip(): diff --git a/plugins/kyberforge/skills/agent-author/references/contract.md b/plugins/kyberforge/skills/agent-author/references/contract.md index 50f6397..cea9336 100644 --- a/plugins/kyberforge/skills/agent-author/references/contract.md +++ b/plugins/kyberforge/skills/agent-author/references/contract.md @@ -61,14 +61,29 @@ 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 +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. 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. +router nowhere — a blocking failure in arrow or `/name` form, and in prose form only a SUGGESTION +nobody is forced to act on, which is the worse outcome because it ships. Verify it before writing +it — do not invent a plausible sibling. + +**Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding +replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports it dangling — nothing in the source lines looks +wrong. Reflow so the whole name sits on one line. The same applies to any backticked skill or +agent name anywhere in a description. 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 diff --git a/plugins/kyberforge/skills/apm-install/README.md b/plugins/kyberforge/skills/apm-install/README.md index 5cacb8f..995a182 100644 --- a/plugins/kyberforge/skills/apm-install/README.md +++ b/plugins/kyberforge/skills/apm-install/README.md @@ -4,7 +4,7 @@ Installs and configures the `apm` (Agent Package Manager) CLI and the agent runt ## What it does -Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). +Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip/pipx), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). ## Usage diff --git a/plugins/kyberforge/skills/apm-install/SKILL.md b/plugins/kyberforge/skills/apm-install/SKILL.md index abb520a..054d161 100644 --- a/plugins/kyberforge/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/skills/apm-install/SKILL.md @@ -1,13 +1,10 @@ --- name: apm-install description: > - Use when the user wants to install the apm (Agent Package Manager) CLI - itself, pin or upgrade its version, set up an air-gapped/enterprise mirror - install, or install and manage an agent runtime that apm drives (Copilot - CLI, Codex, Gemini, generic llm) — "install apm", "set up apm", "pin apm to - a version", "apm runtime setup", "which runtime will apm run pick". Do not - use for authoring apm.yml, scaffolding a package/marketplace, compiling, - packing, publishing, or running apm audit — use apm-workflow for those. + Use when installing, pinning, or upgrading the apm (Agent Package Manager) + CLI itself, or installing and managing an agent runtime apm drives. Not + authoring, publishing, auditing, or dependency installation for an apm + package -> `apm-workflow`. metadata: category: apm source_keys: @@ -16,13 +13,12 @@ metadata: ## Gotchas -- apm does not execute agents itself — it only installs and manages the runtimes that do. "Install apm" and "install a runtime apm manages" are two separate steps; don't conflate them or skip the second when the user actually wants a working agent CLI, not just the package manager. -- The air-gapped/enterprise mirror path needs `GITHUB_URL` and `VERSION` set together against a downloaded `install.sh` — it does not work through the piped one-liner form. -- `pip install apm-cli` requires Python 3.10+; the quick-install script has no such prerequisite. Prefer the quick-install script unless the environment is pip-first. -- On a Debian/externally-managed Python environment (PEP 668), `pip install apm-cli` fails immediately with `error: externally-managed-environment`. Fall back to `pipx install apm-cli` — same PyPI package, but pipx creates an isolated venv and correctly exposes the `apm` binary on `PATH`. -- Installing the Copilot CLI runtime through `apm runtime setup copilot` requires Node.js v22+ and npm v10+ already present — apm does not install Node/npm for you. +- apm never executes an agent itself — it only installs and manages the runtimes that do. Installing apm alone leaves the user with a package manager and no working agent CLI, so Step 2 is required whenever the user actually wants one; skip it only when they explicitly want the package manager alone. +- `apm runtime setup copilot` needs Node.js v22+ and npm v10+ already on `PATH`; apm will not install them for you. -## Install apm +## Step 1 — Install the apm CLI + +If `apm --version` already answers and the user is not pinning or upgrading, skip to Step 2. Default: @@ -31,16 +27,17 @@ curl -sSL https://aka.ms/apm-unix | sh ``` Escape hatches — combine as needed: -- Pin a version: append `@vX.Y.Z` to the piped script's arguments, e.g. `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. -- Custom install directory: set `APM_INSTALL_DIR` on the piped script's command, e.g. `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. -- Air-gapped / GitHub Enterprise mirror: download `install.sh` first, then run it with `GITHUB_URL` and `VERSION` set, e.g. `GITHUB_URL=https://github.corp.com VERSION=v1.2.3 sh install.sh`. -- pip (Python 3.10+ environments): `pip install apm-cli`. -- pipx (externally-managed/PEP 668 environments where plain `pip install` fails, e.g. Debian): `pipx install apm-cli`. -- Manual: download the platform archive from the GitHub releases page, extract, place the binary on `PATH`. + +- **Pin a version** — append `@vX.Y.Z` to the piped script's arguments: `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. +- **Custom install directory** — set `APM_INSTALL_DIR` on the piped script's command: `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. +- **Air-gapped mirror / GitHub Enterprise** — an air-gapped host cannot reach `aka.ms` at all, so get `install.sh` onto the box and run it from disk instead of piping. Point it at the mirror with `APM_RELEASE_BASE_URL` and pin `VERSION`: `APM_RELEASE_BASE_URL=https://mirror.corp/apm VERSION=v1.2.3 sh install.sh`; add `APM_RELEASE_METADATA_URL` instead if you leave `VERSION` unset. `GITHUB_URL` is the GitHub Enterprise host, not a release mirror. All four are ordinary environment variables that also work through the pipe — running from disk is a network constraint, not a script one. +- **pip** — `pip install apm-cli` requires Python 3.10+. Not on an externally-managed (PEP 668) Python such as Debian or Ubuntu, where it hard-fails with `error: externally-managed-environment`; use pipx below. The quick-install script has no Python prerequisite, so prefer it unless the environment is pip-first. +- **pipx** — `pipx install apm-cli` on those PEP 668 environments. Same PyPI package, but pipx builds an isolated venv and exposes `apm` on `PATH`. +- **Manual** — download the platform archive from the GitHub releases page, extract, and place the binary on `PATH`. Verify with `apm --version`. -## Install or manage an agent runtime +## Step 2 — Install or manage an agent runtime Default: diff --git a/plugins/kyberforge/skills/apm-workflow/README.md b/plugins/kyberforge/skills/apm-workflow/README.md index 1996384..1328bed 100644 --- a/plugins/kyberforge/skills/apm-workflow/README.md +++ b/plugins/kyberforge/skills/apm-workflow/README.md @@ -4,7 +4,7 @@ Authors, scaffolds, compiles, and audits apm packages and marketplaces. ## What it does -Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches by requested action to one of five reference files, each self-contained for its concern. +Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches on the resolved flow to one of five reference files; each carries that flow's traps and names a sibling file where one flow genuinely depends on another's detail. ## Before you start @@ -24,10 +24,10 @@ Requires the `apm` binary and (for runtime-driven scripts) an agent runtime alre | File | Purpose | |------|---------| -| `SKILL.md` | Dispatch table and cross-cutting gotchas | -| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, registries | +| `SKILL.md` | Dispatch table and the three gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition, the unchecked `type:` field) | +| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, `includes:`, registries; `type:` and `experimental enable registries` traps | | `references/install.md` | apm install, apm install [PACKAGE_REF], --update, --target agent-skills | -| `references/marketplace.md` | Building/registering a marketplace, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | +| `references/marketplace.md` | Building/registering a marketplace, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | | `references/compile.md` | apm compile / pack / publish / run, claude plugin validate agents/ gotcha | -| `references/audit.md` | apm audit, apm audit --ci, apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | +| `references/audit.md` | apm audit vs apm audit --ci (they check different things), apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | | `references/sources.md` | Provenance chain — research sources that informed this skill | diff --git a/plugins/kyberforge/skills/apm-workflow/SKILL.md b/plugins/kyberforge/skills/apm-workflow/SKILL.md index 15270fe..cd258b4 100644 --- a/plugins/kyberforge/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/skills/apm-workflow/SKILL.md @@ -1,18 +1,9 @@ --- name: apm-workflow description: > - Use when the user wants to author or edit an apm.yml manifest - (dependencies, scripts, compilation, policy, registries), scaffold a new - apm package or marketplace (apm plugin init, apm marketplace init/package - add), install or resolve dependencies declared in apm.yml (apm install, - apm install [PACKAGE_REF]), register a marketplace as a consumer, - compile/pack/publish an apm package for distribution, or validate/audit - apm.yml and installed content (apm audit, apm marketplace check) — even if - the user doesn't say "apm" explicitly, e.g. "set up the package manifest", - "scaffold this as an apm package", "install my apm dependencies", "resolve - apm.yml deps", "build the distributable", "check this passes CI". Do not - use for installing the apm binary itself or setting up an agent runtime — - use apm-install for those. + Use when authoring, installing, or publishing an apm package, its apm.yml and + the dependencies it declares, or an apm marketplace — even when the user does + not say "apm". Not the apm binary or an agent runtime -> `apm-install`. metadata: category: apm source_keys: @@ -21,24 +12,21 @@ metadata: ## Gotchas -- `apm.yml`'s `type:` field (`instructions`, `skill`, `hybrid`, `prompts`) constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later doesn't retroactively validate what's already on disk. -- `includes: auto` publishes the authoritative local layout as-is. Anything narrower needs an explicit repo-path list — don't assume `auto` means "scoped down to what's relevant." Note: `auto` still excludes generic root-level passthrough files (README.md, docs/, sources.md, config files) from the `apm pack` distribution bundle — see `references/compile.md`. -- `apm marketplace add` (registering a marketplace as a *consumer*, pointing at someone else's catalog) and `apm marketplace package add` (registering a package by remote reference — `owner/repo`, host URL, or full URL — into a marketplace you're building) are opposite directions of the same command family — don't conflate them. `package add` does NOT accept local paths; a local package is registered by hand-editing `apm.yml`'s `marketplace.packages[]` directly — see `references/marketplace.md`. -- MCP server secrets (headers, env vars) inside `apm.yml` must use `${VAR}` indirection, never literal values, so they're resolved at install/runtime and never committed to the manifest. -- `apm experimental enable registries` must run before any `registry.*` config takes effect. Declaring a `registries:` block or running `apm config set registry.*` without it silently does nothing — no error, no warning. -- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. +- MCP server secrets in `apm.yml` (headers, env vars) must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed. +- `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect anywhere — configure, install or publish. Without it, declaring one silently does nothing: no error, no warning. +- `apm.yml`'s `type:` selects which primitives are processed and is never checked against what `.apm/` holds, so `apm install` and `apm compile` can exit 0 having shipped none of the ones you expected. Set it to cover every primitive the package ships, and confirm the deployed output, not the exit code. Mechanics: `references/configure.md`. ## Step 1 — Dispatch -| Invocation | Action | Reference | +| Condition | Flow | Reference | |---|---|---| -| `/apm-workflow configure` | Author/edit `apm.yml`; scaffold a new package (`apm plugin init`) | `references/configure.md` | -| `/apm-workflow install` | Resolve/fetch dependencies declared in `apm.yml` (`apm install`, `apm install [PACKAGE_REF]`) | `references/install.md` | -| `/apm-workflow marketplace` | Build a marketplace, register packages into it (local: hand-edit `apm.yml`; remote: `apm marketplace package add`), or register a marketplace as a consumer (`apm marketplace init/check/package add/add`) | `references/marketplace.md` | -| `/apm-workflow compile` | Generate per-target output, bundle, or publish (`apm compile`, `apm pack`, `apm publish`) | `references/compile.md` | -| `/apm-workflow audit` | Validate integrity/policy or wire a CI gate (`apm audit`, `apm audit --ci`) | `references/audit.md` | +| Author or edit `apm.yml`, or scaffold a new package (`apm plugin init`) | configure | `references/configure.md` | +| Resolve or fetch the dependencies `apm.yml` declares (`apm install`, `apm install [PACKAGE_REF]`) | install | `references/install.md` | +| Build a marketplace, register a package into it (local: hand-edit `apm.yml`; remote: `apm marketplace package add`), or register someone else's as a consumer (`apm marketplace init/check/package add/add`) | marketplace | `references/marketplace.md` | +| Generate per-target output, bundle, or publish (`apm compile`, `apm pack`, `apm publish`) | compile | `references/compile.md` | +| Validate integrity/policy or wire a CI gate (`apm audit`, `apm audit --ci`) | audit | `references/audit.md` | -Read only the reference file matching the requested action — each is self-contained for its concern. +Read the reference file matching the resolved flow — it carries that flow's own traps, and names a sibling file wherever one flow genuinely depends on another's detail. ## Step 2 — Execute diff --git a/plugins/kyberforge/skills/apm-workflow/references/audit.md b/plugins/kyberforge/skills/apm-workflow/references/audit.md index 4e2b2c4..48eeda4 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/audit.md +++ b/plugins/kyberforge/skills/apm-workflow/references/audit.md @@ -53,3 +53,7 @@ jobs: ## Claude Code validation is a separate terminal check For packages compiling to the `claude` target, also run `claude plugin validate [--strict]` against the compiled output as a terminal check. `apm audit`/`apm audit --ci` do not check Claude Code-marketplace-specific schema rules (reserved prefixes, the `agents/` stray-file behavior — see `references/compile.md` — etc.), so they don't substitute for it. + +## Gotchas + +- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. diff --git a/plugins/kyberforge/skills/apm-workflow/references/compile.md b/plugins/kyberforge/skills/apm-workflow/references/compile.md index d8c5b5b..0780f24 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/skills/apm-workflow/references/compile.md @@ -40,7 +40,7 @@ Bundles a producer package into a distributable artifact. Default to `--dry-run `includes: auto` does NOT sweep generic root-level passthrough files (README.md, docs/, sources.md, config files, etc.) into the `apm pack` distribution bundle (`build/-`) — only `.apm/` primitives, the compiled `plugin.json`, and the content-aware `.mcp.json` handling above make it into that bundle. This does not affect Claude Code's own plugin loading, which reads a plugin's working directory directly via its `source:` path in `marketplace.json`, not the `apm pack` bundle — but it matters for anyone relying on the packed bundle for distribution via `apm install`. -Run `apm audit` after compile/pack, not before — audit scans deployed/compiled output, not the source `apm.yml` manifest; see `references/audit.md`. +Run `apm audit` after compile/pack, not before — audit scans deployed/compiled output, not the source `apm.yml` manifest. Plain `apm audit` is not the CI-equivalent pass (that is `apm audit --ci`, which checks strictly more); see `references/audit.md`. ## Publish @@ -51,6 +51,8 @@ apm publish --package acme/my-skill Publishes a producer package (root containing `apm.yml`, `.apm/`, and optionally a `registries:` block) to a registry. Always dry-run with `-v` first — publishing is not trivially reversible once a version tag is claimed on a registry. +Publishing to a named registry requires `apm experimental enable registries` to have already run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. + ## Run ```bash diff --git a/plugins/kyberforge/skills/apm-workflow/references/configure.md b/plugins/kyberforge/skills/apm-workflow/references/configure.md index 191104d..808f9c2 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/skills/apm-workflow/references/configure.md @@ -25,9 +25,9 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains `.apm/` contents +- `type` — `instructions | skill | hybrid | prompts`; selects how the package is processed at install/compile time. It is a routing selector, not a constraint on what `.apm/` may contain (see Gotchas) - `targets` — which harnesses this package compiles to (plural list form preferred; legacy singular `target: copilot,claude` CSV form still accepted) -- `includes` — `auto` publishes the authoritative local layout as-is, or list explicit repo paths. Note: `auto` does not sweep generic root-level passthrough files (README.md, docs/, sources.md, config files) into the `apm pack` distribution bundle — see `references/compile.md` +- `includes` — `auto` publishes the authoritative local layout as-is; it is not scoped down to what's relevant, so anything narrower needs an explicit repo-path list. Note: `auto` also does not sweep generic root-level passthrough files (README.md, docs/, sources.md, config files) into the `apm pack` distribution bundle — see `references/compile.md` - `dependencies`/`devDependencies` — `apm`/`mcp`/`lsp` entries; `devDependencies` share the same shape but are excluded from the shipped artifact - `scripts` — named commands runnable via `apm run ` - `compilation` — target/strategy/exclude/placement controls for `apm compile`/`apm pack` @@ -35,8 +35,6 @@ version: 1.0.0 - `registries` — named registry endpoints for shorthand dependency resolution - `marketplace` — owner + packages list; see `references/marketplace.md` for the full marketplace workflow -See `docs/research/docs/microsoft-apm/configuration.md` for the complete annotated schema. - ## Bumping a package's own version (repo policy) apm ships no version-bump command, so `version:` in a package's own `apm.yml` is a hand edit. @@ -68,14 +66,14 @@ from a content diff. ## MCP server secrets -`${VAR}` indirection is required for MCP server secrets in `apm.yml` — see SKILL.md Gotchas. +`${VAR}` indirection is required for MCP server secrets (headers, env vars) in `apm.yml`, never literal values — see SKILL.md Gotchas. ## Registries (config-level, not `apm.yml`) Any git repo is a valid package source by default — no registry required. To declare named registries for shorthand dependency resolution: ```bash -apm experimental enable registries # required first — see SKILL.md Gotchas +apm experimental enable registries # required first — see Gotchas apm config set registry.corp-main.url https://artifactory.corp.example.com/apm apm config set registry.corp-main.token eyJ... apm config set registry.corp-main.default true @@ -85,4 +83,6 @@ apm config set registry.corp-main.default true ## Gotchas +- `apm.yml`'s `type:` field validates nothing about `.apm/`. It selects processing: `instructions` compiles to AGENTS.md only, `skill` installs a native skill only, `prompts` emits commands only, `hybrid` does both (see `apm_cli/models/validation.py`, `PackageContentType`). apm checks only that the value parses to one of those four strings; no check anywhere compares it against the primitives actually on disk, and no mismatch diagnostic exists. A package declaring `type: instructions` while shipping `.apm/skills/` therefore raises no error — the mismatch resolves silently, either by omitting that primitive from the install/compile output or, in apm 0.28.0 where `get_effective_type()` routes off the on-disk layout and never reads the declared field, by ignoring the declared value outright. Both directions are silent: `apm install` and `apm compile` can exit 0 having shipped none of the primitives you expected. Set `type:` to cover every primitive the package ships, and confirm the deployed output rather than the exit code. +- `apm experimental enable registries` must run before any `registry.*` config takes effect. Declaring a `registries:` block or running `apm config set registry.*` without it silently does nothing — no error, no warning. - `apm plugin init ` run with a positional name argument, from inside a directory already named ``, creates a wrongly-nested `//` subdirectory — it treats the positional arg as "create a new project directory named X," not "confirm the current directory is X." Fix: omit the positional argument entirely when already cd'd into the target package directory — run `apm plugin init --yes --target claude,copilot` instead. diff --git a/plugins/kyberforge/skills/apm-workflow/references/install.md b/plugins/kyberforge/skills/apm-workflow/references/install.md index 760c44e..c0b8f25 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/skills/apm-workflow/references/install.md @@ -18,3 +18,5 @@ With no arguments, resolves and installs everything declared under `dependencies `--update` is the escape hatch for a lockfile hash mismatch against upstream — normal `apm install` treats that as drift and won't silently accept it; see `references/audit.md` for the CI-side check (`apm install --frozen`) that fails instead of re-resolving. `--target agent-skills` generates the vendor-neutral output directory instead of a Claude/Copilot-specific one — for IDE-agnostic tool support. + +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. diff --git a/plugins/kyberforge/skills/apm-workflow/references/marketplace.md b/plugins/kyberforge/skills/apm-workflow/references/marketplace.md index 096bc99..68fa29f 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/marketplace.md +++ b/plugins/kyberforge/skills/apm-workflow/references/marketplace.md @@ -104,6 +104,7 @@ hook. ## Gotchas +- `apm marketplace add` and `apm marketplace package add` point in opposite directions and are easy to conflate: `add` registers someone else's catalog for you to *consume*; `package add` registers a package by remote reference (`owner/repo`, host URL, or full URL) into a marketplace you are *building*. `package add` does not accept a local path — register a local package by hand-editing `marketplace.packages[]`, as above. - Only `claude` and `codex` marketplace output profiles exist in apm 0.28.0 — confirmed via source (`apm_cli/marketplace/output_profiles.py`: `MARKETPLACE_OUTPUTS = {claude: ..., codex: ...}`, nothing else). There is no native Copilot marketplace output; `apm pack` will not generate one. A repo needing a Copilot-consumable marketplace manifest must maintain it separately by other means. - A package/plugin `name` compiling to the `claude` target must avoid Claude Code's reserved name prefixes/values — `anthropic-*`, `claude-*`, `agent-skills`, `official-claude-plugins` — otherwise the compiled `.claude-plugin/marketplace.json` is rejected by Claude Code's validator. This is a Claude Code platform constraint, independent of how the manifest gets authored. - Removing a package from `marketplace.packages[]` in `apm.yml` and re-packing changes the public/consumed catalog. Confirm with the user in conversation before removing the entry and running `apm pack` — this is a normal `apm.yml` edit (not a distinct apm-orchestrate operation with its own gate), so the confirmation is conversational, the same as any other consequential manifest edit. diff --git a/plugins/kyberforge/skills/forge/README.md b/plugins/kyberforge/skills/forge/README.md index b8f4857..d76b745 100644 --- a/plugins/kyberforge/skills/forge/README.md +++ b/plugins/kyberforge/skills/forge/README.md @@ -1,12 +1,12 @@ # forge -Guided entry point for building or improving something in kyberforge when the target artifact type isn't decided yet. +Guided entry point for building or improving something in any plugin of this repo when the target artifact type isn't decided yet. ## What it does -Grills the user's intent via `bin:grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. +Grills the user's intent via `grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. -Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and their real terminal check (`apm audit`) is already part of their own flow. +Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and no automatic terminal check either — `apm audit` is a separate `apm-workflow` action, not a closing step of the configure or marketplace flow — so forge verifies those routes by reading the written manifest back against the grilled intent. ## Before you start @@ -22,16 +22,19 @@ Skip forge and call the target skill directly (`/skill-author`, `/agent-author`, ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/sources.md` | Provenance chain — research sources that informed this skill | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — Gotchas, the grill step, the classification dispatch table, and the gates common to every route | +| `references/author-routes.md` | The intent classifies as a skill or an agent/subagent definition — fork-vs-inline judgment and the two-tier verification loop | +| `references/apm-routes.md` | The intent classifies as a plugin or a marketplace entry — always-inline invocation, why these routes get no clean-context recheck, and the manual read-back that stands in for one | +| `references/version-bump.md` | A finished route left the owning package's version unbumped — walk-up rule and the clean-context bump brief | +| `references/sources.md` | Never loaded at runtime — provenance chain for the research sources that informed this skill | ## Routes to | Artifact type | Skill | |---|---| -| Skill | `kyberforge:skill-author` | -| Agent / subagent definition | `kyberforge:agent-author` | -| Plugin | `kyberforge:apm-workflow` (configure) | -| Marketplace entry | `kyberforge:apm-workflow` (marketplace) | +| Skill | `skill-author` | +| Agent / subagent definition | `agent-author` | +| Plugin | `apm-workflow` (configure) | +| Marketplace entry | `apm-workflow` (marketplace) | diff --git a/plugins/kyberforge/skills/forge/SKILL.md b/plugins/kyberforge/skills/forge/SKILL.md index b0318e0..860e9db 100644 --- a/plugins/kyberforge/skills/forge/SKILL.md +++ b/plugins/kyberforge/skills/forge/SKILL.md @@ -1,16 +1,12 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something - but hasn't yet named which of it (skill, agent, plugin, or marketplace - entry) they need — "I want to add something to kyberforge", "not sure if - this should be a skill or a plugin", "help me figure out what to build", - "I have an idea but don't know where it belongs". Grills the intent first, - classifies the target artifact type, then routes to the matching author - skill. Do not use when the user already names the target artifact type or - skill/agent explicitly (e.g. "run /skill-author on my-skill", "create an - agent for X") — route directly to that author skill instead, bypassing - forge. + Use when the user wants to build or improve something but has not yet named + the artifact type — skill, agent, plugin, or marketplace entry; "not sure if + this should be a skill or a plugin", "I have an idea but don't know where it + belongs". Routes to the matching author skill. Do not use when the type is + already named — invoke `skill-author`, `agent-author` or `apm-workflow` + directly. metadata: category: factory source_keys: @@ -21,62 +17,37 @@ metadata: ## Gotchas -- forge is an optional guided entry point, not a gate — the four existing factory skills (`skill-author`, `skill-audit`, `agent-author`, `agent-audit`) plus `apm-workflow` (for plugin/marketplace-entry artifacts) remain directly invokable and forge does not intercept those calls. `plugin-author` and `marketplace-author` were removed per ADR-0015 once issue #90 landed — `apm-workflow` is their sole successor. -- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite sharing a name — `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. Keep this straight when deciding how to invoke a subagent in Step 3. +- forge is an optional guided entry point, not a gate — `skill-author`, `skill-audit`, `agent-author`, `agent-audit` and `apm-workflow` all stay directly invokable, and forge never intercepts a direct call to one. +- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between the two, `references/apm-routes.md` rules the fork out. ## Step 1 — Grill the intent -Call `bin:grill-with-docs` unless a grill session was already performed and is available in the context. -Grilling may surface that the artifact type assumed at the start is wrong, or that the idea splits into more than one artifact. -This step always runs inline, in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth. +Call `grill-with-docs` unless a grill session has already run and is available in the context. -## Step 2 — Classify the artifact type +`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency, so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone. If it does not resolve, grill inline yourself rather than skipping the step: what problem the artifact solves, who invokes it and how, what it must refuse, and which existing skill or plugin already owns part of the job. Say which path you took. -Match the grilled intent against exactly one row (or more than one, if the intent genuinely spans several): +Grilling regularly overturns the artifact type assumed at the start, or splits one idea into several artifacts, so it runs before classification rather than confirming it. Run it inline in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth. -| Intent | Artifact type | Route to | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------| ---------------------------------| -| A reusable capability or workflow the agent should load inline in the main conversation — triggered automatically by description-matching, not a fresh context, and free to bundle its own `references/`, `scripts/`, or `assets/` | Skill | `kyberforge:skill-author` | -| A recurring task needs its own reusable agent/subagent definition — dedicated system prompt, tools, and description, invokable by name across sessions | Agent / subagent definition | `kyberforge:agent-author` | -| A new distributable unit is needed — no existing plugin is the right home for the skill/agent/hook/MCP server being built, or the bundle needs its own manifest, versioning, and install lifecycle separate from what already exists | Plugin | `kyberforge:apm-workflow` (configure — `apm plugin init`) | -| The plugin itself already exists (or was just created) and only its marketplace-facing metadata needs to change — listing it for the first time, or updating its version/description entry — never the plugin's contents | Marketplace entry | `kyberforge:apm-workflow` (marketplace — `apm marketplace package add`) | +## Step 2 — Classify and dispatch -If the intent is genuinely ambiguous between rows even after grilling, ask the user directly rather than guessing. +Match the grilled intent against exactly one row — or more than one, if the intent genuinely spans several artifacts. -Note: plugin and marketplace-entry artifacts route through `kyberforge:apm-workflow` per ADR-0015 — the former `plugin-author` and `marketplace-author` skills were removed once issue #90 landed. +| Intent | Artifact type | Route to | Read | +|---|---|---|---| +| A reusable capability the agent loads inline in the main conversation, triggered by description-matching, free to bundle its own `references/`, `scripts/` or `assets/` | Skill | `skill-author` | `references/author-routes.md` | +| A recurring task needs its own reusable definition — dedicated system prompt, tools and description, invokable by name across sessions | Agent / subagent | `agent-author` | `references/author-routes.md` | +| A new distributable unit — no existing plugin is the right home for the skill, agent, hook or MCP server being built, or the bundle needs its own manifest, versioning and install lifecycle | Plugin | `apm-workflow` (`apm plugin init`) | `references/apm-routes.md` | +| The plugin already exists and only its marketplace-facing metadata changes — a first listing, or a version/description update, never the plugin's contents | Marketplace entry | `apm-workflow` (`apm marketplace package add`) | `references/apm-routes.md` | -This table classifies what to build, not how to run it — a one-off task that merely needs an isolated vs. context-inheriting run (rather than a new, reusable definition) isn't an artifact at all; there's nothing here to route it to. +The table classifies what to build, not how to run it: a one-off task that merely needs an isolated or context-inheriting run is not an artifact and has no row here. If the intent stays genuinely ambiguous between rows after grilling, ask the user rather than guessing. -## Step 3 — Announce, then route +A real artifact that matches no row — a hook, an MCP server, an AGENTS.md, a research doc — has no route here. Say so, hand the user the skill that does own it, and never bend it into a row to make the table fit. -State the classification and which skill(s) will run before invoking anything. +When the intent spans several rows, chain the routes in dependency order — an artifact that must exist on disk before another skill can target it goes first, so `apm-workflow` scaffolds the plugin directory before `skill-author` scaffolds a skill inside it. -**Invoking the author skill(s).** Default to a fork subagent — it inherits the full grilled-intent conversation, so the author skill doesn't need to be re-briefed. Fall back to an inline invocation (same conversation, no subagent) when either is true: -- **Fork is technically unavailable** — already running inside a fork (a fork cannot spawn another fork), a nesting-depth cap is reached, or the environment doesn't support forking. -- **The routed flow needs live user interaction mid-run** that a backgrounded fork can't surface in real time — clarifying questions, confirmation checkpoints, or a HITL gate (e.g. `apm-workflow`'s publish/release steps, or its conversational confirmation before removing a marketplace entry). Judge this from context: if nothing about the routed flow signals a live checkpoint, prefer the fork subagent. +**Announce, then invoke.** State the classification and which skill(s) will run. Then read the reference file for each classified artifact type — only those — and follow it. -`apm-workflow` routes for plugin/marketplace-entry artifacts always run inline — their flows are short, prompt-heavy, or gated, and get no follow-up audit-recheck step to justify running detached (see below). +## Step 3 — Closing gates, common to every route -**After a skill or agent route finishes.** `skill-author` and `agent-author` already close out with their own inline audit (`skill-author` runs `/skill-audit`, `agent-author` invokes `kyberforge:agent-audit` directly) in the same context as the authoring work — that's unchanged. Once that author skill's run has finished, spin up a separate **clean-context subagent** (fresh, not forked, no inherited context) to independently re-run the same audit skill against the finished artifact. This is a distinct verification layer, not a duplicate: the inline audit shares context with the work it's checking and can share its blind spots, while the clean rerun has no stake in the result. - -If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, any actionable finding on its own — loop: re-invoke the author skill (same fork-vs-inline judgment as the initial invocation) to resolve it, then re-run the clean audit again. Repeat until the clean audit comes back with nothing unresolved. Only then is the route done — the same resolve-before-close discipline `skill-author`/`agent-author` already apply to their own inline audit. - -When the intent spans multiple artifact types (e.g. a new skill inside a new plugin, then registering that plugin via `kyberforge:apm-workflow` marketplace), chain the routes in dependency order — an artifact that must exist on disk before another skill can target it goes first (e.g. `apm-workflow` scaffolds the plugin directory via `apm plugin init` before `skill-author` scaffolds a skill inside it). - -## Step 4 — Bump plugin version (if applicable) - -After the routed skill finishes, check if the artifact was created or updated inside a package by walking up from the artifact's path to the nearest ancestor `apm.yml` that declares a top-level `type:` field (`instructions`/`skill`/`hybrid`/`prompts`). An `apm.yml` with no `type:` field is a marketplace-only manifest (see `plugins/kyberforge/docs/research/docs/microsoft-apm/monorepo-and-repo-shapes.md`) — it does not count as a match; skip it and keep walking up. - -**Skip this step if:** -- No ancestor `apm.yml` with a `type:` field is found (the artifact is standalone or scoped to user agent directories) -- The author skill already bumped the package version (check the skill's audit output or completion message for version bump evidence) - -**If a typed `apm.yml` is found and no version bump was done:** - -Invoke `kyberforge:apm-workflow` as a **clean-context subagent** (fresh, not forked) with this brief: - -> "The package at `` gained a new `` (``). Bump the `version` field in that package's `apm.yml`. Determine whether to bump minor (0.1.0) or patch (0.0.1) based on whether this is a new capability (minor) or a fix/refactor (patch). Do not release or tag — just update `apm.yml` and commit." - -Use a clean-context subagent (not forked) so the version bump decision is made independently without anchoring to the earlier authoring context. This gives apm-workflow a clear, isolated directive. - -Report completion to the user: "Updated `` version from X.Y.Z to X.Y.Z to reflect the new ``." +- **Resolve before closing.** A route is finished only when its verification reports nothing unresolved. An actionable finding reopens the route; it is never reported onward as a caveat. +- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did. diff --git a/plugins/kyberforge/skills/forge/references/apm-routes.md b/plugins/kyberforge/skills/forge/references/apm-routes.md new file mode 100644 index 0000000..3e04823 --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/apm-routes.md @@ -0,0 +1,42 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a plugin or marketplace entry to apm-workflow + +Reached from `SKILL.md` Step 2 when the classified artifact is a plugin or a marketplace entry. +Both route to `apm-workflow` — a plugin to its configure flow (`apm plugin init`), a marketplace +entry to its marketplace flow (`apm marketplace package add`). + +No other skill is a candidate for these two rows: `plugin-author` and `marketplace-author` were +removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole successor. + +## Always inline, never forked + +Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or +gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace +entry takes a conversational confirmation — and a backgrounded fork cannot surface those +checkpoints to the user in real time. + +## No clean-context recheck, and no automatic audit + +Skill and agent routes close with a clean-context audit rerun; these two do not, and the omission +is deliberate rather than an oversight. Neither artifact type has an audit skill counterpart to +re-run, so detaching the route to earn a recheck it would never get buys nothing. + +These routes get no automated terminal check either. `apm audit` is a separate action on +`apm-workflow`'s own dispatch table, not a closing step of the configure or marketplace flow a +forge route lands in, so a completion message from either says nothing about it. Do not wait for +one and do not report one you did not see. + +Verify by hand instead. Read back what the route wrote against what the grill settled: + +- **Plugin** — the package directory exists where the intent said it should, and its `apm.yml` + carries the intended `name`, a top-level `type:` field, and a `version`. +- **Marketplace entry** — the entry names that package, points at the source the intent settled + on, and carries the version the package actually declares. + +If the change warrants the full integrity and policy check rather than a read-back, invoke +`apm-workflow` again for its audit action and run `apm audit` deliberately. Then return to +`SKILL.md` Step 3 for the closing gates common to every route. diff --git a/plugins/kyberforge/skills/forge/references/author-routes.md b/plugins/kyberforge/skills/forge/references/author-routes.md new file mode 100644 index 0000000..237debc --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/author-routes.md @@ -0,0 +1,44 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a skill or agent to its author skill + +Reached from `SKILL.md` Step 2 when the classified artifact is a skill or an agent/subagent +definition. Route a skill to `skill-author` and an agent to `agent-author`. The two branches +differ on one axis only — which audit skill verifies the result — and everything below applies to +both. + +## Choose fork or inline + +Default to a **fork subagent**. It inherits the full grilled-intent conversation, so the author +skill does not need re-briefing on what the user asked for or what the grill settled. + +Fall back to an **inline invocation** — same conversation, no subagent — when either holds: + +- **Fork is technically unavailable.** You are already running inside a fork (a fork cannot spawn + another fork), a nesting-depth cap is reached, or the environment does not support forking. +- **The routed flow needs live user interaction mid-run** that a backgrounded fork cannot surface + in real time: clarifying questions, confirmation checkpoints, or a HITL gate. Judge this from + context — if nothing about the flow signals a live checkpoint, prefer the fork. + +## Two-tier verification + +Both author skills already close out with their own inline audit, in the same context as the +authoring work: `skill-author` runs `/skill-audit`, `agent-author` invokes +`agent-audit`. That is tier one, and forge does not change it. + +Tier two belongs to forge. Once the author skill's run has finished, spin up a separate +**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run the +same audit skill against the finished artifact. This is a distinct verification layer, not a +duplicate: the inline audit shares context with the work it is checking and can share its blind +spots, while the clean rerun has no stake in the result. + +If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, +any actionable finding on its own — loop: re-invoke the author skill (same fork-versus-inline +judgment as the first invocation) to resolve it, then re-run the clean audit. Repeat until the +clean audit comes back with nothing unresolved. Only then is the route done. This is the same +resolve-before-close discipline the author skills already apply to their own inline audit. + +Return to `SKILL.md` Step 3 for the closing gates common to every route once the loop closes. diff --git a/plugins/kyberforge/skills/forge/references/sources.md b/plugins/kyberforge/skills/forge/references/sources.md index 5d9aac7..4f065b3 100644 --- a/plugins/kyberforge/skills/forge/references/sources.md +++ b/plugins/kyberforge/skills/forge/references/sources.md @@ -4,15 +4,15 @@ - **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. Grounds Step 3's fork-vs-inline invocation logic: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. -- **Contributing files:** SKILL.md +- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds the fork-vs-inline invocation logic in `references/author-routes.md`, the always-inline decision for the apm routes in `references/apm-routes.md`, and the clean-context bump subagent in `references/version-bump.md`: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. +- **Contributing files:** SKILL.md, references/author-routes.md, references/apm-routes.md, references/version-bump.md - **Status:** `extracted` ## context7-websites-code-claude - **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 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry warning against conflating the two. +- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry in `SKILL.md` warning against conflating the two; nothing else in this skill draws on it, and no `references/` file mentions the `context: fork` field. - **Contributing files:** SKILL.md - **Status:** `extracted` @@ -28,7 +28,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md -- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category. forge has none of that bulk, so a lean SKILL.md-plus-provenance-file shape is spec-legitimate; the `references/sources.md` in this directory exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. +- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: ADR-0020's rule that dispatch is mandatory at two or more mutually exclusive flows, which forge's four-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. - **Contributing files:** SKILL.md - **Status:** `extracted` diff --git a/plugins/kyberforge/skills/forge/references/version-bump.md b/plugins/kyberforge/skills/forge/references/version-bump.md new file mode 100644 index 0000000..5c05039 --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/version-bump.md @@ -0,0 +1,40 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Bumping the package version after a route + +Reached from `SKILL.md` Step 3 after a route has finished. A skill route always lands here: +`skill-author` moves only a skill's own `metadata.version`, which is not the package manifest's +number, so the package version is still behind when it reports done. `agent-author` bumps the +resolved package's `apm.yml` itself at plugin/APM scope, and `apm-workflow`'s configure flow +carries the same policy — read those routes' output before acting here, because a second bump for +one change is wrong. + +## Find the owning package + +Walk up from the artifact's path to the nearest ancestor `apm.yml` that declares a top-level +`type:` field (`instructions`, `skill`, `hybrid` or `prompts`). + +An `apm.yml` with **no** `type:` field is a marketplace-only manifest: it lists packages rather +than declaring one, so it does not count as a match. Skip it and keep walking up. + +Skip this step entirely if no ancestor `apm.yml` carries a `type:` field: the artifact is then +standalone or scoped to a user agent directory, and there is no package to version. + +## Delegate the bump + +Invoke `apm-workflow` as a **clean-context subagent** — fresh, not forked — with this +brief: + +> "The package at `` gained a new `` (``). Bump the +> `version` field in that package's `apm.yml`. Determine whether to bump minor (0.1.0) or patch +> (0.0.1) based on whether this is a new capability (minor) or a fix/refactor (patch). Do not +> release or tag — just update `apm.yml` and commit." + +Clean context rather than a fork is the point: the bump decision is made independently, without +anchoring on the authoring conversation that just argued for the artifact's significance. + +Then report to the user: "Updated `` version from `` to `` to reflect the +new ``." diff --git a/plugins/kyberforge/skills/skill-audit/README.md b/plugins/kyberforge/skills/skill-audit/README.md index d46ca78..277c836 100644 --- a/plugins/kyberforge/skills/skill-audit/README.md +++ b/plugins/kyberforge/skills/skill-audit/README.md @@ -6,7 +6,7 @@ Audit a skill directory against the agentskills.io specification and the house c 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 five dimension groups, loading one rubric from `references/` per group +3. Applies qualitative checks across five dimension groups — always loading `references/finding-criteria.md`, then one rubric from `references/` per group the criteria put in play 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). @@ -35,7 +35,8 @@ Provide the path to the skill directory to audit when invoking. | `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` | 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/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file loaded on every run; it decides which rubrics below are worth loading | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked (`disable-model-invocation`) contract, the three-part shape, when an indirect trigger is warranted, near-miss exclusions, and a before/after pair | | `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 | diff --git a/plugins/kyberforge/skills/skill-audit/SKILL.md b/plugins/kyberforge/skills/skill-audit/SKILL.md index 5cc3a82..1a66612 100644 --- a/plugins/kyberforge/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/skills/skill-audit/SKILL.md @@ -30,14 +30,14 @@ Resolve all three paths against this skill's own directory so they work from a r ```bash bash scripts/validate.sh bash scripts/validate-provenance.sh -scripts/vale-wrap.sh /SKILL.md +bash scripts/vale-wrap.sh /SKILL.md ``` -`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both. +`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation. 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. -`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. +`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a usage or environment error, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason. Never grade an exit 2 as a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. `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: @@ -53,9 +53,9 @@ Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/ ## Step 3 — Qualitative audit -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). +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body-discipline | `references/body-discipline.md` | @@ -63,7 +63,7 @@ Load a dimension's rubric before judging that dimension. Each is self-contained, | file-structure, internal-consistency | `references/file-structure.md` | | formatting, scripts | `references/formatting-and-scripts.md` | -Cite file and line number for every finding. +Each rubric is self-contained and grounded in the agentskills.io specification plus the house context budget (ADR-0020). Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index 4f4af51..31f28c6 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -31,7 +31,16 @@ Include content the agent lacks: 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: +from the ADR-0020 gate because no such file exists on disk. + +**A dispatch table satisfies this requirement on its own.** A table row already pairs a condition +with a target, which is exactly what the literal form encodes; restating each row underneath as a +prose conditional duplicates the routing in the one body whose whole purpose is to be short. Where a +body dispatches, audit the table for condition/target completeness and stop there — do not require +the conditional form as well. The literal form is what a body needs when it loads a reference +*without* a dispatch table: a single mid-procedure deepening, an escape hatch, an error path. + +Move: - Lookup tables and spec restatements - Output schemas, templates and example blocks @@ -71,10 +80,28 @@ table** plus the gates common to every branch, and each flow lives in its own se `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. +The reference shape in this repo is `apm-workflow`: a **294-word body** dispatching to 3,154 words +of references across five mutually exclusive flows. Its whole-file count is 348 words — cite 294 +when calibrating a body, or the conflation this section warns against reappears in the finding +itself. The 3,154 counts the five flow files only; `references/sources.md` is a provenance record +and is never loaded at runtime, so counting it inflates the dispatched total. + +### What earns the wiring exemption + +A dispatch table earns the exemption above on its properties, not on which skill it appears in. +Audit any dispatching body against these four: + +- Every flow the skill handles has a row, and every row names a target file that exists on disk. +- Each row pairs a condition the agent can evaluate from the request with exactly one target. A row + keyed on a literal slash invocation fails this: a model-invoked activation never produces that + string, so the routing silently falls to whatever else the row carries. +- One line after the table tells the agent to read the file its row matched, and only that one. +- The gates every branch needs sit in the body, not inside one flow's file — see the reachability + precondition below. + +A table missing any of the four is not exempt, and the literal-conditional requirement applies to it +as written. The exemption covers the wiring form only: every other rule in this file applies to a +dispatching skill exactly as it applies to any other. ## Gotchas sections @@ -96,19 +123,32 @@ Constraints: `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. + coming. This one is the auditor's call — no script detects it. The Fix is conditional: delete the + Gotcha only if the surviving copy is reachable from every branch that needs it — see the + reachability precondition below. - **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: +Worked negative example — **`git-commits` v0.1.2 at commit `5e23250`, a fixed pre-retrofit +snapshot, not the current file.** The live skill is v0.1.3 and matches none of the citations below; +they are quoted as they stood before the ADR-0020 retrofit, and are not to be refreshed against +`HEAD`. The snapshot is reachable only from a checkout of the authoring repo — an installed plugin +cache holds no git history and no such path — so read the citations below as quoted rather than +going to look for the file. From a checkout: + +```text +git show 5e23250:/.apm/skills/git-commits/SKILL.md +``` + +That body carried twelve Gotchas, four of which restated content already below them or already in +the description: | Gotcha | Restates | |---|---| -| `:31` "Communicates SemVer impact" | the description | +| `:31` "SemVer mapping is not optional" | 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` | @@ -118,6 +158,25 @@ All four are FAILs under the paraphrase rule. The entry count and the section's 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. +### The paraphrase rule has a reachability precondition + +**A Gotcha that restates a step may be deleted only when the surviving copy is reachable from every +branch that needs it.** In a dispatch body it usually is not: each flow file is loaded alone, so a +step in one is invisible to an invocation that took another branch. When the restated rule is a +safety gate more than one flow needs, the Fix is to **move it into the body's common-gates section**, +never to drop it in favour of the per-flow copy. + +Row four is the case that proves it. Following the rule literally, the retrofit deleted the +always-loaded secrets Gotcha and kept step 2 of `references/create-commit.md` — but `git-commits` +dispatches to exactly one flow file, and `references/rewrite-history.md` stages changes and runs +`--amend`, which commits newly staged content exactly as a fresh commit does. A grep for `secret` +across the skill in that state returned one hit, on a path two of three branches never reach: that +branch could commit a credential with no check anywhere in its loaded context, against this repo's +governance hard prohibition. v0.1.3 carries the rule as gate 2 of "Gates on every flow" instead. + +So check reachability before writing the Fix. Rows one to three are unaffected — the description is +loaded on every invocation, and confirmation is likewise a common gate rather than a per-flow step. + ## Calibrating control **Be prescriptive** when operations are fragile, consistency matters, or a specific sequence must be @@ -146,23 +205,5 @@ Use pypdf, pdfplumber, PyMuPDF, or pdf2image... Use pdfplumber for text extraction. For scanned PDFs requiring OCR, use pdf2image instead. ``` -## Auditing guidance - -Flag as FAIL if: - -- 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: - -- 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 -- Content that only one branch reaches is inlined where a `references/` file would serve +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/skills/skill-audit/references/description-quality.md index 411d178..ef027f8 100644 --- a/plugins/kyberforge/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/skills/skill-audit/references/description-quality.md @@ -40,8 +40,11 @@ A model-invoked description carries exactly three things: 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. + a real skill directory or agent file in the authoring source. `validate.sh` checks that + deterministically and grades it by notation: an unresolved `/name` or arrow target is an ERROR + and reaches the report as a Structure FAIL, while an unresolved prose-form target ("use `y` + instead") is only a SUGGESTION unless a second target in the same sentence resolves. Take the + script's tier as given and report it once, under Structure. Everything else belongs in the body or in `README.md`. @@ -80,38 +83,5 @@ description: > (`data-model` is illustrative. In a real description the target has to resolve.) -## 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. -- **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: - -- **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. +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/file-structure.md b/plugins/kyberforge/skills/skill-audit/references/file-structure.md index 805ba14..33c3531 100644 --- a/plugins/kyberforge/skills/skill-audit/references/file-structure.md +++ b/plugins/kyberforge/skills/skill-audit/references/file-structure.md @@ -24,9 +24,19 @@ knows to look at. Flag any other directory as a FAIL. ## 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/`. +resolving there. Flag a path in `SKILL.md`, `scripts/`, `references/` or `assets/` when it +**resolves outside the skill directory** — an absolute repo path +(`plugins//skills//` and its APM-native equivalent `.apm/skills//`), a +plugin-root path (`docs/`, `bin/`), or a `../` chain that leaves the skill root. + +Resolve before flagging, twice over: + +- **Resolve the path.** `$SKILL_DIR/../assets/templates` climbs one level from a `scripts/` + directory and lands back inside the same skill, so it resolves in a cache install and is not a + finding. A bare `../` is not the defect; leaving the skill is. +- **Skip fenced code blocks.** A path inside a fenced block is an example, and rubrics quote outside + paths deliberately as negative examples of what not to write. Flag a fenced path only when the + surrounding prose presents it as the form to copy. **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 @@ -39,9 +49,10 @@ 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. + not runtime references. They are expected to be unresolvable after install, so + `validate-provenance.sh` does not treat an absent path as a FAIL — it emits an INFO naming the + slug and stating that checks 7 and 8 did not run for it. Flagging them as broken references + 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. @@ -60,19 +71,5 @@ The skill has to agree with itself. Three checks: 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md new file mode 100644 index 0000000..05eb85d --- /dev/null +++ b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md @@ -0,0 +1,138 @@ +--- +source_keys: + - agentskills-spec + - agentskills-best-practices + - agentskills-optimizing-descriptions + - agentskills-using-scripts +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +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. +- **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. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0 of + `references/description-quality.md`. +- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent + of the 400-character house ceiling above. + +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. + +**An unresolved boundary target is not graded here.** `validate.sh` owns that call and tiers it by +notation — `/name` or an arrow form is an ERROR, the bare prose form a SUGGESTION unless a second +target in the same sentence resolves — and Step 1 has already filed it under `### Structure` at that +tier. Re-grading it as a description FAIL puts one target in the report twice at two tiers. What is +left to judgment here 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. + +## body-discipline — `references/body-discipline.md` + +Flag as FAIL if: + +- 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 that every branch reaching the Gotcha also + reaches +- 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: + +- 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 +- Content that only one branch reaches is inlined where a `references/` file would serve + +## patterns — `references/patterns.md` + +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 + +## file-structure and internal-consistency — `references/file-structure.md` + +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 path that resolves outside the skill directory appears outside the two exempt locations, in + prose rather than in a fenced example +- `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 + +## formatting and scripts — `references/formatting-and-scripts.md` + +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/skills/skill-audit/references/formatting-and-scripts.md b/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md index 9462bdf..0b9d23e 100644 --- a/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md +++ b/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md @@ -44,20 +44,5 @@ follow from that: 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/patterns.md b/plugins/kyberforge/skills/skill-audit/references/patterns.md index fb144ca..80cffa1 100644 --- a/plugins/kyberforge/skills/skill-audit/references/patterns.md +++ b/plugins/kyberforge/skills/skill-audit/references/patterns.md @@ -50,19 +50,5 @@ forms are judgment. `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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/sources.md b/plugins/kyberforge/skills/skill-audit/references/sources.md index fd89ca7..a01c5dd 100644 --- a/plugins/kyberforge/skills/skill-audit/references/sources.md +++ b/plugins/kyberforge/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, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.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/finding-criteria.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, references/patterns.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-optimizing-descriptions @@ -31,7 +31,7 @@ - **URL:** https://agentskills.io/skill-creation/optimizing-descriptions.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** How to systematically test and improve skill descriptions for triggering accuracy — eval queries, trigger rate testing, train/validation splits, optimization loop -- **Contributing files:** SKILL.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/description-quality.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-evaluating-skills @@ -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, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-quickstart diff --git a/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md b/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md index 89c7788..a1ddb24 100644 --- a/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md +++ b/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md @@ -99,9 +99,12 @@ Three ways to read the result wrong: ## 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. +- **`validate-provenance.sh` printed nothing *and exited 0*.** That is a pass, not a skip — it + exits 0 silently when the skill has no `source_keys` and no `references/sources.md`, and nothing + to validate is not a finding. Check the exit code before you believe the silence: a target that + is not a directory, a directory holding no `SKILL.md`, a missing or extra argument, and an absent + `python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it + as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings. - **`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. diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh index edfe211..4ed3629 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh @@ -13,17 +13,37 @@ Arguments: Exit codes: 0 All checks passed (or nothing to validate) 1 One or more checks failed + 2 Usage error, or the argument is not a skill directory + +An exit code of 2 is NOT a finding. SKILL.md tells the auditor to surface a +non-zero exit as findings, so a usage error leaving exit 1 with nothing on +stdout was indistinguishable from a clean-but-failing run. Environment and +argument problems exit 2; only real findings exit 1. Checks performed: 0 source_keys present but references/sources.md absent 1 FILL IN: placeholders in sources.md 2 source_keys in SKILL.md → slug exists in sources.md - 3 source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) - 4 Contributing files listed in sources.md exist on disk + 3 source_keys in references/*.md → slug exists in sources.md (INFO if no + source_keys; an explicit 'source_keys: []' declares the file house-authored + and passes silently) + 4 Contributing files listed in sources.md exist on disk. An explicit + '(none)' skips silently; a Contributing files block this parser cannot + read is reported as an INFO saying checks 4 and 5 did not run, never + skipped silently. 5 Contributing files back-reference the parent slug in their source_keys 6 Research doc field present and not placeholder - 7 Slug in sources.md present in upstream research doc (INFO only) + 7 Slug in sources.md present in upstream research doc (INFO only). A section + annotation ('§ ...', '→ ...', '(...)') is stripped before the path is + resolved; a path that still does not resolve is reported as an INFO saying + checks 7 and 8 did not run, never skipped silently. 8 Extracted non-(none) slug in research doc present in sources.md + + Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE + INDEX — a file whose basename is sources.md, whose H2 headings ARE source + slugs. A Research doc pointing at a topic document is reported as an INFO + saying the two checks are not applicable, and every other reason they do not + run is announced the same way. EOF } @@ -32,11 +52,57 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then exit 0 fi +# Usage and environment problems exit 2, findings exit 1. See the usage text +# above for why the two must not share a code. This is a deliberate divergence +# from validate.sh, which has no 2 tier: validate.sh always prints PASS lines, +# so a usage error there is visibly not a findings report. This script prints +# NOTHING on a clean run, so exit 1 plus empty stdout was the only signal a +# caller got either way. if [[ $# -lt 1 ]]; then echo "Error: skill-dir is required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 +fi + +# Extra positional arguments were silently dropped, so a typo'd flag or a second +# path looked like it had been honoured. +if [[ $# -gt 1 ]]; then + echo "Error: expected exactly one argument, got $#: $*" >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + +# python3 is a HARD dependency. Without this preflight a missing interpreter +# produced 'line NN: python3: command not found' and exit 127 — an exit code no +# caller maps to anything, from a message that names this script's line number +# rather than the missing dependency. +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 provenance checks entirely 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 2 +fi + +# A path that is not a directory, or a directory that is not a skill, used to +# reach the Python body, find no sources.md and no source_keys, take the +# "nothing to validate" early exit and report exit 0 with no output — which +# references/validation-scripts.md explicitly told the auditor to read as a +# pass. A typo'd target was therefore indistinguishable from a clean skill. +# vale-wrap.sh hard-errors on a nonexistent path for exactly this reason. +if [[ ! -d "$1" ]]; then + echo "Error: not a directory: $1" >&2 + echo " Why: a nonexistent target would otherwise report a silent pass." >&2 + echo " Fix: pass the path of the skill directory to validate." >&2 + exit 2 +fi + +if [[ ! -f "$1/SKILL.md" ]]; then + echo "Error: not a skill directory (no SKILL.md): $1" >&2 + echo " Why: a directory with no SKILL.md has no provenance chain to validate, and reporting that as a pass hides the wrong-target mistake." >&2 + echo " Fix: pass the skill directory itself, not its parent or its references/ subdirectory." >&2 + exit 2 fi python3 -u - "$1" <<'PYTHON' @@ -44,13 +110,67 @@ import sys import os import re +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and every finding this script prints contains an em dash. +# Pinning only the reads moved the crash from the read to the write — a +# UnicodeEncodeError inside print_findings(), which loses the whole report +# after all the checks have already run. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) sources_md_path = os.path.join(skill_dir, "references", "sources.md") refs_dir = os.path.join(skill_dir, "references") # --- Helpers --- -PLACEHOLDER_RE = re.compile(r'(? "references/a.md" + return re.sub(r'\s*\(.*$', '', entry).strip() + + # Inline form: value on the same line, comma-separated, no notes. cf_m = re.search(r'^\- \*\*Contributing files:\*\* (.+)$', block, re.MULTILINE) + if cf_m: + value = cf_m.group(1).strip() + if value.startswith("(none"): + return [] + return [p for p in (strip_note(x) for x in value.split(",")) + if p] or None + + # Bullet form: heading on its own line, one file per following bullet. + cf_m = re.search(r'^\*\*Contributing files:\*\*\s*$', block, re.MULTILINE) if not cf_m: return None - return cf_m.group(1).strip() + files = [] + for line in block[cf_m.end():].splitlines(): + line = line.strip() + if not line: + if files: + break + continue + if not line.startswith("- "): + break + entry = line[2:].strip() + if entry.startswith("(none"): + return [] + entry = strip_note(entry) + if entry: + files.append(entry) + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== -def parse_research_doc(content, slug): - """Find the Research doc value for a given slug H2 in content.""" +def parse_research_docs(content, slug): + """Every Research doc value under a given slug H2, in document order. + + The caller uses the first and reports the rest. Returning only the first — + what this did before — meant a second '- **Research doc:**' line in one + entry was silently ignored, so an author who added a doc rather than + replacing one got checks 7 and 8 run against the old path and no hint that + the new one was never looked at. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL ) m = pattern.search(content) if not m: - return None + return [] block = m.group(1) - rd_m = re.search(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE) - if not rd_m: - return None - return rd_m.group(1).strip() + return [v.strip() for v in + re.findall(r'^\- \*\*Research doc:\*\* (.+)$', block, re.MULTILINE)] + +# A Research doc value is a path, and very often a path PLUS an annotation +# naming the section the slug came from: +# +# plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) +# plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)` +# .../pre-commit/hooks-reference.md § "pre-commit-hooks (official collection)" +# +# os.path.isfile() is false for every one of those strings, and checks 7 and 8 +# used to skip SILENTLY whenever the path did not resolve. The effect was that +# both checks were dead on eight of the nine git skills — git-history, the one +# skill writing a bare path, was the only place they ran, which is why it was +# the only skill ever reporting a check-7 INFO. Strip the annotation before +# resolving, and report when the result still does not resolve: a check that +# quietly does not run is worse than one that fails. +RESEARCH_DOC_ANNOTATION_RE = re.compile(r'[§→(]') + +def strip_research_doc_annotation(value): + """Path part of a Research doc value, with any section annotation removed.""" + return RESEARCH_DOC_ANNOTATION_RE.split(value, maxsplit=1)[0].strip() + +def research_doc_is_none(value): + """True when a Research doc value declares that no research doc backs the slug. + + Both '(none)' and the bare 'none — org convention, ...' spelling are in + use; recognising only the parenthesised one would report the other as an + unresolvable path. Checked BEFORE the annotation strip, because '(none)' + is itself a parenthesis and would strip to the empty string. + """ + return re.match(r'\(?none\b', value.strip(), re.IGNORECASE) is not None + +# The Status value is what gates check 8, so every spelling this parser fails +# to read is a check that does not run. Two were unreadable: +# +# - **Status:** `extracted` — partial fetch (a trailing note) +# **Status:** (the bullet form, the same +# - `extracted` shape parse_contributing_files +# already accepts) +# +# Both used to parse to a string that compared unequal to "`extracted`", and +# check 8 skipped on that inequality without a word. Returning the BACKTICKED +# TOKEN — not the whole line — is what makes the trailing note harmless, and it +# lets the caller name the actual status when it announces a skip. +STATUS_TOKEN_RE = re.compile(r'^`([^`]*)`') + def parse_status(content, slug): - """Find the Status value for a given slug H2 in content.""" + """Find the Status value for a given slug H2 in content. + + Returns the status with its backticks stripped ('extracted', 'referenced', + 'no content extracted'), or None when the entry has no Status line. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -134,10 +410,28 @@ def parse_status(content, slug): if not m: return None block = m.group(1) + + raw = None st_m = re.search(r'^\- \*\*Status:\*\* (.+)$', block, re.MULTILINE) - if not st_m: - return None - return st_m.group(1).strip() + if st_m: + raw = st_m.group(1).strip() + else: + st_m = re.search(r'^\*\*Status:\*\*\s*$', block, re.MULTILINE) + if not st_m: + return None + for line in block[st_m.end():].splitlines(): + line = line.strip() + if not line: + continue + if not line.startswith("- "): + break + raw = line[2:].strip() + break + if raw is None: + return None + + token = STATUS_TOKEN_RE.match(raw) + return token.group(1).strip() if token else raw def find_repo_root(start_dir): """Walk up from start_dir until we find a directory containing .git.""" @@ -153,13 +447,22 @@ def find_repo_root(start_dir): findings = [] has_fail = False +# A finding identical in every field is the same finding, and the same file is +# now reached by more than one check — the walk that looks for source_keys and +# check 3 both read every references/*.md, so an unreadable one would otherwise +# be reported twice with the same words. Distinct findings about the same file +# still both appear. +def _record(entry): + if entry not in findings: + findings.append(entry) + def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix, None)) + _record(("FAIL", desc, fpath, why, fix, None)) def emit_info(desc, fpath, note): - findings.append(("INFO", desc, fpath, None, None, note)) + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): for entry in findings: @@ -181,11 +484,22 @@ def print_findings(): # --- Scan for any file with source_keys --- -def file_has_source_keys(fpath): +def emit_unreadable(rel, exc): + """Report a file this script cannot decode. Never a silent skip.""" + emit_fail( + f"File is {exc}", + rel, + f"'{rel}' cannot be decoded, so its frontmatter — and any source_keys in it — " + f"cannot be read. This used to be swallowed by a bare 'except Exception: return False', " + f"which reported the unreadable file as having no source_keys and therefore as clean.", + f"Re-save '{rel}' as UTF-8." + ) + +def file_has_source_keys(fpath, rel): try: - with open(fpath) as f: - content = f.read() - except Exception: + content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) return False fm, _ = parse_frontmatter(content) if fm is None: @@ -198,26 +512,33 @@ def find_files_with_source_keys(): for root, dirs, files in os.walk(skill_dir): # Skip hidden dirs dirs[:] = [d for d in dirs if not d.startswith('.')] - for fname in files: + for fname in sorted(files): if fname.endswith('.md'): abs_path = os.path.join(root, fname) - if file_has_source_keys(abs_path): - rel = os.path.relpath(abs_path, skill_dir) + rel = os.path.relpath(abs_path, skill_dir) + if file_has_source_keys(abs_path, rel): results.append((rel, abs_path)) return results sources_md_exists = os.path.isfile(sources_md_path) files_with_source_keys = find_files_with_source_keys() -# Early exit: nothing to validate +# Early exit: nothing to validate. The scan itself can raise a finding — an +# unreadable file — so print before leaving; the clean case still prints +# nothing and exits 0. if not sources_md_exists and not files_with_source_keys: - sys.exit(0) + print_findings() + sys.exit(1 if has_fail else 0) # Load sources.md if it exists sources_content = None if sources_md_exists: - with open(sources_md_path) as f: - sources_content = f.read() + try: + sources_content = read_text(sources_md_path) + except EncodingError as exc: + emit_unreadable("references/sources.md", exc) + print_findings() + sys.exit(1) sources_slugs = set(parse_h2_slugs(sources_content)) else: sources_slugs = set() @@ -248,8 +569,11 @@ for line in sources_content.splitlines(): # --- Check 2: source_keys in SKILL.md → slug exists in sources.md --- skill_md_path = os.path.join(skill_dir, "SKILL.md") if os.path.isfile(skill_md_path): - with open(skill_md_path) as f: - skill_content = f.read() + try: + skill_content = read_text(skill_md_path) + except EncodingError as exc: + emit_unreadable("SKILL.md", exc) + skill_content = "" skill_fm, _ = parse_frontmatter(skill_content) skill_source_keys = parse_source_keys(skill_fm) for slug in skill_source_keys: @@ -262,24 +586,43 @@ if os.path.isfile(skill_md_path): ) # --- Check 3: source_keys in references/*.md → slug exists in sources.md (INFO if no source_keys) --- +# os.walk, not os.listdir: find_files_with_source_keys() above already walks +# references/ recursively, so a source_keys-bearing file in +# references// was collected there — and then never validated here, +# because the flat listdir could not see it. The two halves of the same check +# disagreed about which files exist. if os.path.isdir(refs_dir): - for fname in sorted(os.listdir(refs_dir)): - if not fname.endswith('.md'): - continue - if fname == "sources.md": - continue - fpath = os.path.join(refs_dir, fname) + ref_paths = [] + for root, dirs, files in os.walk(refs_dir): + dirs[:] = sorted(d for d in dirs if not d.startswith('.')) + for fname in sorted(files): + if not fname.endswith('.md'): + continue + fpath = os.path.join(root, fname) + if os.path.relpath(fpath, refs_dir) == "sources.md": + continue + ref_paths.append(fpath) + for fpath in ref_paths: rel = os.path.relpath(fpath, skill_dir) - with open(fpath) as f: - ref_content = f.read() + try: + ref_content = read_text(fpath) + except EncodingError as exc: + emit_unreadable(rel, exc) + continue ref_fm, _ = parse_frontmatter(ref_content) ref_keys = parse_source_keys(ref_fm) if not ref_keys: + # An explicit `source_keys: []` is a deliberate declaration that + # the file is house-authored, and passes silently. The INFO is for + # files that never said either way. + if declares_empty_source_keys(ref_fm): + continue emit_info( f"No source_keys frontmatter", rel, "This references file has no source_keys — provenance cannot be verified. " - "Add source_keys frontmatter listing the slugs from references/sources.md that informed this file." + "Add source_keys frontmatter listing the slugs from references/sources.md that informed this file, " + "or declare an explicit 'source_keys: []' if the file is house-authored and has no external source." ) else: for slug in ref_keys: @@ -296,14 +639,51 @@ if os.path.isdir(refs_dir): repo_root = find_repo_root(skill_dir) # Collect all research doc paths we'll check (for Check 8) -research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it +research_docs_seen = {} # abs_path → (rel_path, slugs referencing it, content) -for slug in parse_h2_slugs(sources_content): - # Check 4: Contributing files exist - cf_value = parse_contributing_files(sources_content, slug) - if cf_value and not cf_value.startswith("(none"): - # Split by comma - cf_files = [p.strip() for p in cf_value.split(",") if p.strip()] +# Every per-slug parser below — parse_contributing_files, parse_research_docs, +# parse_status — locates its block with pattern.search(), so a slug written +# twice resolves to the FIRST block every time. Iterating the raw heading list +# therefore checked the first block's fields twice and the second block's +# never: a duplicated slug is half-validated, and looked fully validated. The +# duplicate is announced and the repeat visit dropped. +all_slugs = parse_h2_slugs(sources_content) +unique_slugs = [] +for _slug in all_slugs: + if _slug in unique_slugs: + continue + unique_slugs.append(_slug) + _count = all_slugs.count(_slug) + if _count > 1: + emit_info( + f"Duplicate '## {_slug}' entry in sources.md — only the first block is checked", + f"references/sources.md (## {_slug})", + f"'## {_slug}' appears {_count} times. Every field parser here takes the first match, so the " + f"second and later blocks' Contributing files, Research doc and Status are never validated — " + f"checks 4, 5, 6, 7 and 8 did not run for them. " + f"Merge the blocks into one entry, or give each a distinct slug and reference it from source_keys." + ) + +for slug in unique_slugs: + # Checks 4 and 5: Contributing files exist, and back-reference the slug. + # `[]` and None are NOT the same answer here. `[]` is the author writing + # "(none)" — there is nothing to check and the skip is correct. None is a + # Contributing-files block this parser cannot read, and skipping THAT + # silently disables both checks on the one entry least likely to be right, + # which is the failure mode parse_contributing_files' own docstring warns + # about. Say so out loud instead, the same way an unresolvable Research doc + # value does. + cf_files = parse_contributing_files(sources_content, slug) + if cf_files is None: + emit_info( + f"Contributing-file checks skipped for '{slug}' — the Contributing files block could not be parsed", + f"references/sources.md (## {slug})", + f"The '## {slug}' entry has no Contributing files list this parser can read — a missing field, a bare heading, '*' bullets, a numbered list, or prose all read as unparsable rather than as an empty declaration. " + f"Checks 4 and 5 did not run for this slug, so nothing verified that its contributing files exist or name it back. " + f"Write the value as '- **Contributing files:** ', or as a '**Contributing files:**' heading followed by '- ' bullets — " + f"or record '(none)' if this source contributed no files." + ) + elif cf_files: for cf_rel in cf_files: cf_abs = os.path.join(skill_dir, cf_rel) if not os.path.isfile(cf_abs): @@ -318,8 +698,11 @@ for slug in parse_h2_slugs(sources_content): # Skip sources.md itself if cf_rel == "references/sources.md": continue - with open(cf_abs) as f: - cf_content = f.read() + try: + cf_content = read_text(cf_abs) + except EncodingError as exc: + emit_unreadable(cf_rel, exc) + continue cf_fm, _ = parse_frontmatter(cf_content) cf_keys = parse_source_keys(cf_fm) if slug not in cf_keys: @@ -331,7 +714,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 6: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"references/sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; checks 7 and 8 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( f"Research doc field missing", @@ -346,39 +739,115 @@ for slug in parse_h2_slugs(sources_content): f"The '## {slug}' entry has an unfilled Research doc value.", f"Set '- **Research doc:**' to a real path relative to repo root, or '(none)' if not applicable." ) - else: - # Check 7: Upstream forward — slug should appear in research doc - if repo_root and not rd_value.startswith("(none"): - rd_abs = os.path.join(repo_root, rd_value) - if os.path.isfile(rd_abs): - with open(rd_abs) as f: - rd_content = f.read() + elif not research_doc_is_none(rd_value): + # Check 7: Upstream forward — slug should appear in research doc. + # Every path out of here that does NOT run the check says so out loud. + rd_path = strip_research_doc_annotation(rd_value) + if not repo_root: + emit_info( + f"Upstream checks skipped for '{slug}' — no repo root above the skill directory", + f"references/sources.md (## {slug})", + f"'{rd_value}' is a path relative to the repo root, but no ancestor of the skill directory contains a .git entry, " + f"so it cannot be resolved. Checks 7 and 8 did not run for this slug. " + f"Run this script against a skill inside a checkout." + ) + elif not rd_path: + emit_info( + f"Upstream checks skipped for '{slug}' — Research doc value names no path", + f"references/sources.md (## {slug})", + f"The Research doc value '{rd_value}' is entirely annotation — stripping the section marker leaves no path. " + f"Checks 7 and 8 did not run for this slug. " + f"Give the value a file path relative to the repo root, or record '(none)' if no research doc backs this entry." + ) + else: + rd_abs = os.path.join(repo_root, rd_path) + if not os.path.isfile(rd_abs): + emit_info( + f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' does not exist", + f"references/sources.md (## {slug})", + f"'{rd_value}' resolves to '{rd_path}' relative to the repo root and no file is there. " + f"Checks 7 and 8 did not run for this slug, so nothing verified that the research doc still backs it. " + f"Point the value at one existing file — a brace expansion, a comma-separated list of paths, or a bare section title does not resolve — " + f"or record '(none)' if no research doc backs this entry." + ) + elif os.path.basename(rd_path) != "sources.md": + # Checks 7 and 8 both assume the Research doc is a research + # SOURCE INDEX — a sources.md whose H2 headings ARE source + # slugs. 30 of the 121 corpus entries point instead at a TOPIC + # DOCUMENT (remotes.md, gitflow.md, api-reference.md), whose + # H2s are headings like '## Core Philosophy'. A slug can never + # match one, so check 7 reported all 30 as "slug not found" — + # every one a false positive — and check 8, aimed at documents + # that carry no '- **Status:**' line at all, was saved from a + # matching flood of false FAILs only by an UNANNOUNCED skip on + # that missing status. The premise, not the corpus, was wrong. + # + # A topic-document reference is a legitimate, useful value; it + # just is not something these two checks can verify. Say that + # once, out loud, instead of failing 30 entries for it. + emit_info( + f"Upstream checks not applicable for '{slug}' — research doc '{rd_path}' is a topic document, not a source index", + f"references/sources.md (## {slug})", + f"Checks 7 and 8 match slugs against the H2 headings of a research source index — a file named 'sources.md', " + f"where each H2 IS a source slug. '{os.path.basename(rd_path)}' is a topic document, so its H2s are section " + f"headings and no slug will ever match one. Checks 7 and 8 did not run for this slug. " + f"This needs no fix: point the value at the research corpus's own sources.md only if you want the " + f"provenance link machine-verified." + ) + else: + try: + rd_content = read_text(rd_abs) + except EncodingError as exc: + emit_info( + f"Upstream checks skipped for '{slug}' — research doc '{rd_path}' is {exc}", + f"references/sources.md (## {slug})", + f"'{rd_path}' could not be decoded, so checks 7 and 8 did not run for this slug. " + f"Re-save the research doc as UTF-8." + ) + continue rd_slugs = set(parse_h2_slugs(rd_content)) if slug not in rd_slugs: emit_info( - f"Slug '{slug}' not found as H2 in research doc '{rd_value}'", + f"Slug '{slug}' not found as H2 in research doc '{rd_path}'", f"references/sources.md (## {slug})", - f"The research doc '{rd_value}' does not have a '## {slug}' heading. " + f"The research doc '{rd_path}' does not have a '## {slug}' heading. " f"The provenance link may be imprecise — the slug name in sources.md may differ from the research doc's heading." ) - # Track for Check 8 + # Track for Check 8. The content is carried with the entry so + # check 8 reuses this read rather than decoding the file a + # second time, with a second chance to fail differently. if rd_abs not in research_docs_seen: - research_docs_seen[rd_abs] = (rd_value, set()) + research_docs_seen[rd_abs] = (rd_path, set(), rd_content) research_docs_seen[rd_abs][1].add(slug) # --- Check 8: Upstream reverse --- -for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): - with open(rd_abs) as f: - rd_content = f.read() +for rd_abs, (rd_rel, known_slugs, rd_content) in research_docs_seen.items(): for rd_slug in parse_h2_slugs(rd_content): # Parse this slug's Contributing files and Status in the research doc rd_cf = parse_contributing_files(rd_content, rd_slug) rd_status = parse_status(rd_content, rd_slug) - # Skip if contributing files start with (none - if rd_cf and rd_cf.startswith("(none"): + # Skip if the research doc explicitly records no contributing files + if rd_cf == []: continue - # Skip if status is not `extracted` - if rd_status != "`extracted`": + # Skip if status is not `extracted` — and say so when the skip is what + # kept the slug out of the FAIL below. A status of `referenced` or + # `no content extracted` is a real reason not to demand the slug, but + # it was applied in silence, so an entry that should have been in + # sources.md and a status line nobody had updated produced the same + # output: nothing. Only a MATERIAL skip is announced; when the slug is + # already in sources.md the check passes either way and there is no + # fail-open to disclose. + if rd_status != "extracted": + if rd_slug not in sources_slugs: + shown = f"`{rd_status}`" if rd_status else "absent" + emit_info( + f"Check 8 skipped for research-doc slug '{rd_slug}' — its Status is {shown}, not `extracted`", + f"{rd_rel} (## {rd_slug})", + f"'{rd_rel}' has '## {rd_slug}' with contributing files but Status {shown}, and this skill's " + f"sources.md has no '## {rd_slug}' entry. Check 8 only demands an entry for an `extracted` slug, " + f"so it did not run here. If that status is stale — the content was extracted and the line was never " + f"updated — this skill is missing a source entry; if it is accurate, nothing needs doing." + ) continue # This slug should be in sources.md if rd_slug not in sources_slugs: diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh index f9c0df7..e5ce8f3 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh @@ -59,6 +59,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") @@ -179,9 +197,27 @@ def _collect_package(pkg_dir, names): 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()) + # A directory is a skill only if it HOLDS a SKILL.md. An empty + # leftover — a deleted skill whose directory survived, a scaffolding + # stub, an editor's stray mkdir — is untracked by git, so it exists + # on the machine that made it and nowhere else. Counting it made a + # boundary target resolve locally and dangle in a fresh clone: the + # same install-dependence the deployed-tree rule above exists to + # remove, arriving through a different door. + if os.path.isfile(os.path.join(path, 'SKILL.md')): + 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)): + # The same rule one directory over, which until now had no + # counterpart here at all: the skills branch above tests for a + # SKILL.md, the agents branch took every glob hit on trust. A + # DIRECTORY named `ghost-agent.md` matches `*.md` and glob does not + # tell the two apart, so a leftover of that shape resolved a routing + # target on the machine holding it and dangled everywhere else — + # identical install-dependence, arriving through the one door + # nobody guarded. + if not os.path.isfile(path): + continue base = os.path.basename(path) if base.endswith('.agent.md'): base = base[:-len('.agent.md')] @@ -372,8 +408,17 @@ def known_targets(start_dir): # 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`. +# `Not -> `. The example that motivated it is gone: +# diagnose's process chain "fix -> regression-test", which without the +# gate read as a route to a non-existent `regression-test` skill, was cut +# when issue #99 retrofitted that description. So the gate is currently +# UNEXERCISED — gating and not gating produce the same verdict corpus-wide. +# Keep it anyway. It is a false-positive guard against prose no one has +# written yet, and any new process chain re-arms it. Unexercised is not the +# same as unnecessary, and the branch it guards is still load-bearing: the +# bare-arrow rule is the sole extractor for three real targets in +# kyberforge's audit skills (agent-audit -> agent-author, agent-audit -> +# skill-audit, skill-audit -> skill-author), all written unbackticked. # * 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 @@ -451,6 +496,66 @@ def known_targets(start_dir): # ambiguity to resolve, and an author who wants a route checked unconditionally # has two ways to say so. # +# BOTH FORMS ARE SWEPT FOR ON THEIR OWN, and that is a repair of the promise +# above rather than a widening of it. Until the sweeps existed, notation was +# only ever seen as the OBJECT OF A ROUTE VERB (`use +# /name`) or as the tail of a `not ... ->` clause with no `;` or sentence end in +# between. Every one of these therefore exited 0 in total silence — no ERROR, no +# SUGGESTION, not even the target's name: +# Do not use for Y — /no-such-skill instead. +# Do not use for Y; /no-such-skill handles that. +# Do not use for Y (/no-such-skill covers it). +# Do not use for Y — that is /no-such-skill's job. +# Do not use for Y — defer to /no-such-skill. +# Do not use for Y — /no-such-skill. +# Do not use for Y; -> no-such-skill covers it. +# For W, /no-such-skill is the right entry point. +# The target was never EXTRACTED, so the notation-first rule in _add() had +# nothing to apply itself to and the "always blocks" promise was false for the +# ordinary way an author writes the thing. The SUGGESTION tier made it worse +# than a gap: its printed remedy tells the author to "write it as `/name` or +# `-> name` and it will be checked properly", and taking that advice turned a +# visible SUGGESTION into silence — the gate teaching the one edit that blinds +# it. +# +# THE TWO SWEEPS ARE GATED DIFFERENTLY, and the asymmetry is the whole point. +# `/name` is Claude Code's invocation syntax and nothing else — no English +# sentence contains one by accident — so the ADR-0020 amendment and +# docs/spec/gates.md both promise it blocks UNCONDITIONALLY, for any name. So +# NOTATION_SLASH is swept over every sentence, boundary marker or not. Gating it +# on BOUNDARY_MARKER made that promise false for the last sentence of +# Do not use for Z — use /real-skill instead. +# For W, /no-such-skill is the right entry point. +# which exited 0 in total silence: the boundary clause is one sentence up, so +# the sweep never looked at the sentence carrying the broken route. Extraction is +# per-sentence by design (corroboration is scoped to one sentence), which is +# exactly what made the gap invisible. +# +# NOTATION_ARROW stays gated on BOUNDARY_MARKER, and so does the backtick sweep. +# Neither form is unambiguous: `-> name` is also how a process chain is written +# ("reproduce -> minimise -> regression-test") and a code span is how a tool, a +# file and a skill are all cited. Ungating either would fire on prose that +# carries no routing intent at all — the false-positive class this whole +# extractor is tuned against. +# +# BOTH `/name` PATTERNS REFUSE A TOKEN THAT IS PART OF A PATH: a following `/`, +# or a `.` followed by a non-space, means `references/foo.md`, `docs/a/b.md` or +# `https://x/y`, not a route. A sentence's closing `.` is not followed by a +# non-space, so `— /no-such-skill.` still counts. +# +# THAT GUARD IS WRITTEN `(?![\w-])` AND NOT `\b`, because `\b` is not a guard at +# all here: it holds after a hyphen, so when the trailing lookahead rejected the +# full segment the engine simply backtracked to a shorter hyphen-terminated +# prefix and reported THAT as a route. Every one of these was a hard blocking +# ERROR naming a skill nobody had written: +# the config lives at /opt-tools/bin/thing. -> 'opt' +# see /api-docs/v2.md for the schema. -> 'api' AND 'api-docs' +# the file /no-such-skill.md documents it. -> 'no-such' +# `(?![\w-])` forbids the shortened prefix outright, so the whole segment is +# rejected as the path it is. MARKED_TARGET carries the same guard: it had no +# trailing lookahead whatsoever, so `see /api-docs/v2.md` raised the second of +# the two errors above through the route-verb path rather than the sweep. +# # 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. @@ -461,7 +566,8 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps. NOTATION_SLASH runs over EVERY +# sentence; NOTATION_ARROW is scoped to a boundary sentence by its caller (see +# the asymmetry note in the header). NOTATION_SLASH is deliberately not a reuse +# of MARKED_TARGET's `/name` alternative: that one only ever runs behind a route +# verb or an arrow, and it may match a namespaced or path-adjacent token in +# positions this free-standing sweep must refuse. +# NOTATION_ARROW is ARROW_BOUNDARY minus its leading `\bnot\b%s*?`, which is +# what made `Do not use for Y; -> no-such-skill covers it.` invisible: +# CLAUSE_BODY cannot cross the `;`, so the clause's own punctuation disarmed the +# check. Dropping that prefix costs the one false positive the bare-arrow bullet +# above names — a process chain ending in a hyphenated word, `Instead, reproduce +# -> minimise -> regression-test.` — and costs it only in a sentence that already +# carries a BOUNDARY_MARKER. That exposure is neither new nor larger: the same +# chain written `Do not use for X — reproduce -> regression-test.` was already a +# hard ERROR under ARROW_BOUNDARY, so this changes which boundary words reach the +# arrow, not whether prose can. An author who means the chain and not a route +# writes it in its own sentence, where neither pattern looks. +NOTATION_SLASH = re.compile( + r"(?|→)\s*(%s)\b" % NAME_HYPH, re.I) +# CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT +# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a +# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, +# `.vale.ini` — was invisible to both patterns below, and the two resulting +# failures were different sizes (issue #110): +# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep +# still extracted the target, so the route was checked, but the gate +# reported "no boundary clause" on a clause that was present and working. +# Three authors in two retrofit waves reworded a correct clause to satisfy +# the regex, one of them stripping the very filename that discriminates the +# skill from its neighbour. +# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only +# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill` +# produced no target, no dangling report and no missing-clause SUGGESTION. +# Silence, not noise — the worse of the two failure modes. +# A dot inside a filename is followed by a non-space; a sentence-ending dot is +# followed by whitespace or by end of string. So the class admits a `.` only +# when the next character is not whitespace, which crosses `AGENTS.md` and +# still stops at a real sentence end. +CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))" +ARROW_BOUNDARY = re.compile( + r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, 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) +BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, 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" @@ -499,9 +646,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) # 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. +# re.I here too, and NOT as a tidy-up: this was the one pattern in the file +# built without it, contradicting the uniformity note on CONT_*/ARROW_* above. +# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is +# where an abbreviation most often lands — matched none of the lookbehinds, so +# the clause split at the abbreviation, the corroborating target was stranded on +# the far side of the cut, and a genuinely dangling target silently demoted from +# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described +# directly above, still live for exactly the capitalised half of the input. SENTENCE_SPLIT = re.compile( u'(? name` reached this function with + strict=True from its two call sites, but `/name` did not, so it fell to + _terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The + target then reached unresolved_targets() unblockable — and, before the + companion fix there, unreported as well. `... use /no-such-skill + afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an + author who wants a route checked unconditionally. + """ if not name: return + notation = _notation(text, start, arrow) + if strict is None and notation: + strict = True out.append((name, _terminal(text, end) if strict is None else strict, - _notation(text, start, arrow))) + notation)) def _scan(text, route_re, cont_re, out): @@ -604,7 +774,19 @@ def _extract_sentence(sentence): for match in ARROW_BOUNDARY.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) + # `/name` wherever it sits, in ANY sentence — not only where a route verb or + # an arrow happens to precede it, and NOT only inside a boundary sentence. + # See the EXPLICIT ROUTE NOTATION note in the header for the eight phrasings + # this recovers and for why silence was the failure mode. The sweep takes no + # follower test: _add() reads the notation first and marks it. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) if boundary: + # The arrow and backtick forms are ambiguous in ordinary prose, so they + # stay scoped to a sentence that carries a boundary marker. + for match in NOTATION_ARROW.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) for match in BACKTICK.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1)) return out @@ -623,6 +805,85 @@ def boundary_targets(description): return sorted({name for name, _, _ in _extract(description)}) +def _arrow_targets(description): + """Names extracted from ARROW notation specifically. + + Kept apart from boundary_targets() because the arrow form is the one shape + that ALWAYS names a target: ADR-0020's `Not -> `. A clause + written that way from which nothing could be extracted is a parse failure + that deserves its own message, and telling it apart needs the arrow targets + alone rather than every target in the description. + """ + out = [] + for sentence in SENTENCE_SPLIT.split(description): + for match in ARROW_MARKED.finditer(sentence): + name, _, _ = _first(match) + if name: + out.append(name) + for match in ARROW_BOUNDARY.finditer(sentence): + out.append(match.group(1)) + return out + + +def boundary_clause_status(description): + """'absent', 'unparsed' or 'present' — three outcomes, not two. + + Issue #110's standing request: the gate must distinguish "no boundary + clause" from "boundary clause I could not parse". Reporting the first for + the second sends the author hunting for a problem that is not there, and + three of them reworded a correct clause to satisfy a regex instead. + + 'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was + detected and NO target came out of it. The arrow form always names one, so + zero targets means the name is written in a shape the extractor cannot see + — a single-word bare target (`Not X -> forge`, which has to be written + `` `forge` `` or `/forge`) is the live example, since single-word names are + deliberately not matchable bare. + + A PROSE clause yielding no target is NOT reported: "Do not use for anything + else" is a complete and legitimate boundary clause that names nowhere to go. + """ + if BOUNDARY_ARROW.search(description) and not _arrow_targets(description): + return 'unparsed' + if has_boundary_clause(description): + return 'present' + return 'absent' + + +def multi_target_arrow_clauses(description): + """[(first, second)] for arrow clauses naming more than one target. + + Issue #107: only the FIRST target after an arrow is resolved. The + conjunction continuation (CONT_*) is wired to the prose route verbs and + never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither + resolved nor reported, and then printed "1 of 1 boundary target(s) resolve" + on a clause naming two — a gate under-reporting its own coverage, which is + the one failure mode ADR-0020 says a gate must not have. + + The clause is REJECTED rather than the arrow scan extended. Extending it + would widen the resolver's deliberately conservative false-positive tuning + across every arrow in the corpus; rejecting costs nothing and makes the + one-arrow-per-target convention — already what every retrofitted gitea + skill does in practice — explicit instead of folkloric. The caller emits a + SUGGESTION telling the author to split. + """ + hits = [] + for sentence in SENTENCE_SPLIT.split(description): + matches = (list(ARROW_MARKED.finditer(sentence)) + + list(ARROW_BOUNDARY.finditer(sentence))) + for match in matches: + first, _, _ = _first(match) + if not first: + continue + cont = CONT_ANY.match(sentence, match.end()) + if not cont: + continue + second, _, _ = _first(cont) + if second: + hits.append((first, second)) + return hits + + def unresolved_targets(description, known): """Targets resolving to nothing, split into (blocking, reported). @@ -639,6 +900,17 @@ def unresolved_targets(description, known): 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. + + A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed + whitelist of maybe eighty words, so the follower rule says "this token is + outside a list I keep" and not "this is prose" — and the old `continue` + turned that into invisibility at every tier. The gate then failed OPEN on + its own unfamiliarity: any target followed by a word nobody thought to + enumerate was neither blocked nor mentioned, so the check that did not run + said nothing about not running. The follower rule may withdraw the power to + BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note + above is the argument for it — but it may not withdraw visibility, which is + the same rule the corroboration tier already follows. """ blocking, reported = set(), set() for sentence in SENTENCE_SPLIT.split(description): @@ -647,7 +919,10 @@ def unresolved_targets(description, known): 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: + if key in known: + continue + if not may_dangle: + reported.add(name) continue if notation or (resolved - {key}): blocking.add(name) @@ -727,6 +1002,47 @@ def description_value(fm_text): return re.sub(r'\s+', ' ', value).strip() +def hand_invoked(fm_text): + """True when the frontmatter marks this file as reached only by hand. + + `disable-model-invocation: true` removes a skill from the model-visible + listing entirely — it is not preloaded, and the Skill tool refuses to call + it — so its description is never matched against user intent. ADR-0020 and + skill-author's contract give such a skill ONE plain human-facing sentence: + no trigger list, no boundary clause. No validator knew the field existed + (issue #108), so the boundary-clause SUGGESTION fired on exactly the shape + the contract mandates, and its remedy — "add a boundary clause so the router + knows where NOT to send this skill" — was addressed to a router that cannot + see the skill at all. An author who followed the advice made the file worse. + + Only the ROUTING rules are lifted. The body word budget still applies: the + body is loaded on invocation like any other, and competes with the caller's + live conversation the same way. So does the 400-character description FAIL — + a hand-invoked description is not preloaded, but it is still the one line + the user reads when choosing from the `/` menu, and the ceiling is the + outlier stop rather than the style target. + + A parse failure returns False rather than raising. This is a MODIFIER on + other checks, not a check of its own: the frontmatter's validity is decided, + and failed, by description_value() on the same text, and raising a second + exception here would report one broken file twice with two different + diagnoses. + """ + try: + data = yaml.safe_load(fm_text) + except Exception: + return False + if not isinstance(data, dict): + return False + value = data.get('disable-model-invocation') + if isinstance(value, str): + # PyYAML already resolves the unquoted YAML 1.1 booleans, so this only + # catches a QUOTED "true" — which a host reads as truthy and which no + # gate should treat as opting back in to the routing rules. + return value.strip().lower() in ('true', 'yes', 'on') + return value is True + + # --- 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 @@ -841,7 +1157,15 @@ def missing_reference_pointers(body, skill_dir): end = masked.find('\n', match.end()) if end < 0: end = len(masked) - if REFERENCE_PAST.search(masked[start:end]): + # The pointer's OWN SPAN is excised before the sweep. Run over the + # whole line, the past-tense test matched the very path it was judging, + # so a file exempted itself by its NAME: `references/deprecated-api.md`, + # `references/removed-flags.md` and `references/gone.md` produced no + # ERROR at all, while `references/missing.md` — an identical break — + # errored. The exemption is about what the SENTENCE says about the + # pointer, never about what the pointer is called. + line = masked[start:match.start()] + masked[match.end():end] + if REFERENCE_PAST.search(line): continue if REFERENCE_QUALIFIER.search(masked[start:match.start()]): continue @@ -900,6 +1224,15 @@ except FrontmatterError as exc: dir_name = os.path.basename(skill_dir) +# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation: +# true` takes the skill out of the model-visible listing entirely, so the +# trigger/capability/boundary rules and the 250-character routing target do not +# apply to it — the audit's own references/description-quality.md Step 0 says +# so, and until this line existed no check here knew the field existed. What the +# flag does NOT lift: the body word budget and the 400-character description +# ceiling. See the shared resolver's hand_invoked(). +by_hand = hand_invoked(fm) + # --- Checks --- # name present @@ -1014,10 +1347,13 @@ if desc: 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: + elif dlen > DESC_SUGGEST_CHARS and not by_hand: 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") + elif by_hand: + ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character " + f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)") else: ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") @@ -1071,13 +1407,41 @@ if gotchas is not None: # 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): +# +# THREE outcomes, not two: "no boundary clause" and "boundary clause I could not +# parse" are different findings, and reporting the first for the second sends +# the author hunting for a problem that is not there (issue #110). +# +# Skipped entirely for a hand-invoked skill — the contract gives it one plain +# sentence with no boundary clause, so the finding would be wrong and its remedy +# names a router that cannot see the skill (issue #108). +if desc and by_hand: + ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger " + "rules do not apply; audited as one plain human-facing sentence") +elif desc: + status = boundary_clause_status(desc) + if status == 'present': ok("description has a boundary clause") - else: + elif status == 'absent': 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") + else: + suggest("description has an arrow boundary clause (\"Not X -> y\") from which no " + "target could be read, so the dangling-target check did not run on it — " + "the clause is PRESENT and unparsed, not missing. Most often the target is " + "a single word, which is deliberately not matchable bare because " + "`research`, `triage` and `forge` are all ordinary English: write it as " + "`name` or /name") + # One arrow, one target. A second name after the same arrow is resolved by + # nothing and reported by nothing, so the clause claims coverage it does not + # have and this script printed "1 of 1 boundary target(s) resolve" on a + # clause naming two (issue #107). + for first, second in multi_target_arrow_clauses(desc): + suggest(f"an arrow boundary clause names more than one target ('{first}', then " + f"'{second}') and only the first is resolved — the second is checked by " + f"nothing. Split it into one arrow per target: \"Not X -> {first}. " + f"Not Y -> {second}.\"") # --- ADR-0020: resolvable boundary targets --------------------------------- # The resolution universe comes from the SKILL's own location: the authoring diff --git a/plugins/kyberforge/skills/skill-author/references/contract.md b/plugins/kyberforge/skills/skill-author/references/contract.md index 986514d..e6795bf 100644 --- a/plugins/kyberforge/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/skills/skill-author/references/contract.md @@ -47,15 +47,22 @@ explicitly" only where the user's natural phrasing genuinely omits the domain wo for `git-commits`, where the user says "commit". Adding one everywhere is what inflated this corpus, and it was deleted as a blanket rule. -**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 SKILL.md itself**: the nearest ancestor holding +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the SKILL.md +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 skill'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 boundary clause naming a target -outside that universe sends the router nowhere and fails the audit. Check the target exists before -writing it — do not invent a plausible sibling name. +outside that universe sends the router nowhere — a blocking failure in arrow or `/name` form, and +in prose form a SUGGESTION nobody is forced to act on, which is the worse outcome because it ships. +Check the target exists before writing it — do not invent a plausible sibling name. 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 @@ -63,6 +70,18 @@ or Codex, and `.apm/` source compiles for all three, so routing to one is a port 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. +**One arrow, one target.** The resolver reads only the first name after an arrow, so a second is +checked by nothing and the gate emits a SUGGESTION naming both. Split instead of conjoining: +`Not -> first-skill. Not -> second-skill.`, never +`Not -> first-skill or second-skill`. + +**Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding +replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports it dangling — this is what broke +`gitea-labels-milestones`, and nothing in the source lines looks wrong. Reflow so the whole name +sits on one line. The same applies to any backticked skill or agent name anywhere in a description. + **Length.** 250 characters SUGGESTION, 400 characters FAIL, counting the frontmatter value only with YAML folding resolved. The agentskills.io 1,024-character spec limit is unchanged and sits above both. The SUGGESTION tier is the one that moves the average; treat 250 as the target and 400 @@ -118,11 +137,25 @@ If , read `references/.md`. A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it. +**A dispatch table is the wiring.** Where the body dispatches, a row already pairs a condition with +a target, which is what the literal form encodes — so do not restate each row underneath as a prose +conditional. That duplicates the routing in the one body whose whole purpose is to be short. The +literal form is what a reference loaded *without* a table needs: a mid-procedure deepening, an +escape hatch, an error path. A table earns this on four properties — every flow has a row and every +row's target exists on disk; each row pairs exactly one target with a condition the agent can +evaluate from the request, never a literal slash invocation; one line after the table names the +matched file as the only one to read; and the gates every branch needs sit in the body, not inside +one flow's file. That last one is the property the `git-commits` v0.1.2 failure turned on, and it is +the one a dispatch split is most likely to break. `skill-audit`'s `references/body-discipline.md` +carries the audit-side form of the same exemption; the two lists are the same four properties, and +an edit to either belongs in both. + **Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch table and the gates common to every branch; each flow gets its own self-contained `references/` -file. Exemplar: the `apm-workflow` skill — a **421-word body** dispatching to 3,006 words of -references. Calibrate against 421: that file's whole-file count is 554 words, and aiming at that -number instead overshoots the body budget by ~30%. +file. Exemplar: the `apm-workflow` skill — a **294-word body** dispatching to 3,154 words of +references across five flow files. Calibrate against 294: that file's whole-file count is 348 +words, and aiming at that number instead overshoots the body budget by ~18%. The 3,154 excludes +`references/sources.md`, which is a provenance record and is never loaded at runtime. **Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after the frontmatter's closing `---`. @@ -130,10 +163,20 @@ the frontmatter's closing `---`. ## Gotchas section - Each entry must state a fact that **contradicts a reasonable default** — something the agent - gets wrong by acting sensibly. "Never commit secrets" is not one; the agent already knows. + gets wrong by acting sensibly. "Write a descriptive commit message" is not one; the agent does it + unprompted and nothing in the environment argues against it. A safety gate is a different case, + even where the agent knows the rule — see the paraphrase bullet below. - More than five entries is a SUGGESTION — five is the guideline, not a ceiling. -- A Gotcha that paraphrases a step in the body below it is a **FAIL**. If the rule is already a - step, it is not a gotcha. +- A Gotcha that paraphrases a step in the body below it is a **FAIL**, but deleting it is correct + only when the surviving copy is **reachable from every branch that reaches the Gotcha**. In a + dispatch body it often is not: each flow file loads alone, so a step in one is invisible to an + invocation that took another branch. Where the restated rule is a safety gate more than one flow + needs, move it into the body's common-gates section instead of dropping it. `git-commits` v0.1.2 + is the worked failure: the retrofit deleted its always-loaded "never commit secrets" Gotcha in + favour of a step in one flow file, and left the history-rewrite branch — which stages and + `--amend`s, committing new content exactly as a fresh commit does — with no such check anywhere + in its loaded context, against this repo's governance hard prohibitions. v0.1.3 carries the rule + as a gate on every flow. - A Gotchas section exceeding 25% of the body is a SUGGESTION. - Place the section near the top — a gotcha read after the mistake is worthless. diff --git a/plugins/kyberforge/skills/skill-author/references/retrofit.md b/plugins/kyberforge/skills/skill-author/references/retrofit.md index 7eeae03..37fa55d 100644 --- a/plugins/kyberforge/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/skills/skill-author/references/retrofit.md @@ -96,6 +96,13 @@ them for you. After every retrofit that adds, removes or renames a file: content moved into it, and remove any file the retrofit deleted. This is the one that gets missed: `sources.md` keeps citing sections of `SKILL.md` that no longer exist, the provenance check still exits 0, and the stale claim survives review. +- [ ] **Reachability of every relocated gate.** For each Gotcha or gate the retrofit moved out of + the body, list the flows that need it and confirm each one reaches the surviving copy. A gate + that lands in a single flow file is invisible to every other branch, and no gate detects + that: `/skill-audit` reads whichever file it was handed, and the word counts improve either + way. Where more than one flow needs it, the copy belongs in the body's common-gates section, + not in a flow file. Grep the skill for the gate's key term and check every branch that hits + zero. - [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new file as missing `source_keys`. @@ -115,14 +122,19 @@ milestone), that's gitea-labels-milestones directly. Do not use for pull request or for local git branch/commit work (use gitea-branches or git-branches). ``` -After, 240 characters: +After, the 290 characters that shipped: ```text -Use when reading or writing Gitea issues — list, read, create, comment on, label, close, or -search — even when the user does not say "Gitea". Not pull requests -> `gitea-prs`. Not label or -milestone definitions -> `gitea-labels-milestones`. +Use when reading or writing Gitea issues — "create an issue", "what issues are open", "close +issue #N", "comment on issue #N", "search issues for X" — even when the user does not say +"Gitea". Not pull requests -> `gitea-prs`. Not label or milestone definitions -> +`gitea-labels-milestones`. ``` +The retrofit kept the quoted-phrasing register and dropped the verb list, not the other way round. +Either register is admissible — what is banned is carrying both. Choose whichever routes better +for the skill in hand; here the quoted user phrasings do, because they are how people actually ask. + What came out, and why: | Removed | Why | @@ -132,7 +144,7 @@ What came out, and why: | `Composes gitea-labels-milestones for all label inference/resolution and milestone lookup` | A composition note. It changes no routing decision and belongs in `README.md`. | | The parenthetical `(create/edit/delete a label, create/close a milestone)` | Capability enumeration inside a boundary clause. The boundary needs the target, not its feature list. | | The `gitea-branches` / `git-branches` boundary | Dropped entirely. Neither was ever going to win an issue request, so the clause defended against nothing — an invented boundary costs characters and buys no routing accuracy. | -| `Do not use for pull requests (use gitea-prs)` prose form | Kept, but rewritten as `Not pull requests -> \`gitea-prs\`.` The rewrite buys characters and one uniform shape for the router — not safety. Both forms are parsed **and** target-checked, so a typo in the prose form dangles exactly as an arrow typo does. | +| `Do not use for pull requests (use gitea-prs)` prose form | Kept, but rewritten as `Not pull requests -> \`gitea-prs\`.` The rewrite buys characters, one uniform shape for the router, **and** a stricter check: an unresolved arrow target is a blocking ERROR, while an unresolved prose target is only a SUGGESTION unless another target in the same sentence resolves. The prose form does not dangle as loudly. | What stayed: one trigger clause, one capability clause, the indirect trigger (genuinely warranted here — people say "create an issue", not "create a Gitea issue"), and the boundary clauses. @@ -146,7 +158,9 @@ a skill that was never going to compete, not a second real one. **Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at -the start of the next fold into `gitea-labels- milestones`. `validate.sh` then reads the target as -`gitea-labels`, finds no such skill, and reports a dangling boundary target — the live finding on -`gitea-issues` today. Reflow the line so the whole name sits on one of them. The same applies to -any backticked skill or agent name in a description. +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports a dangling boundary target. This is not +hypothetical — it is how `gitea-labels-milestones` broke (issue #100). It is fixed: the corpus +carries no dangling target today, and the repo's test suite pins that set as empty, so a +reintroduction fails the suite rather than joining a backlog. Reflow the line so the whole name +sits on one of them. The same applies to any backticked skill or agent name in a description. diff --git a/plugins/lint/.apm/skills/vale-config/README.md b/plugins/lint/.apm/skills/vale-config/README.md index 2a5d0bc..b49138a 100644 --- a/plugins/lint/.apm/skills/vale-config/README.md +++ b/plugins/lint/.apm/skills/vale-config/README.md @@ -19,5 +19,5 @@ Describe what you want configured: initial setup, adding a third-party style pac | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/configuration-reference.md` | Full `.vale.ini` field and rule-header reference | +| `references/configuration-reference.md` | Full `.vale.ini` fields, rule-header fields, and frontmatter-scope behaviour | | `references/sources.md` | Research sources backing the Vale configuration guidance | diff --git a/plugins/lint/.apm/skills/vale-config/SKILL.md b/plugins/lint/.apm/skills/vale-config/SKILL.md index c5b192f..7511181 100644 --- a/plugins/lint/.apm/skills/vale-config/SKILL.md +++ b/plugins/lint/.apm/skills/vale-config/SKILL.md @@ -2,27 +2,26 @@ name: vale-config description: > - Use when installing or configuring Vale, the cross-platform prose/style linter — setting up - .vale.ini, choosing a StylesPath, adding built-in, third-party, or custom styles, and activating - them per file glob via BasedOnStyles. Covers the setup side of Vale only: getting a project from - "no Vale config" to "vale sync runs clean and BasedOnStyles is wired up correctly". Use even if the - user doesn't say "Vale" explicitly — "set up prose linting", "lint our docs for style", "enforce a - vocabulary/terminology list in markdown" all apply. Do not use when the user wants to actually run - Vale and interpret its output on existing config — use vale-run for that. + Use when installing or configuring Vale, the prose/style linter — writing a `.vale.ini` whose + styles are fetched and actually activated — even when the user says only "set up prose + linting". Not running Vale on an existing config -> `vale-run`. metadata: category: lint - version: "0.1.0" + version: "0.1.2" source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas -- Installing the `vale` binary installs no styles, but only *package* styles need fetching. A fresh `.vale.ini` naming a style in `BasedOnStyles` that is declared in `Packages` will fail or find nothing until `vale sync` downloads it. A built-in style (`Vale`) or a style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no sync. -- `.vale.ini` is order-sensitive: global (core) settings first, then the optional `[formats]` section, then glob sections (`[*]`, `[*.md]`, …). Settings in a glob section only apply to files matching that glob. -- `Packages` (top-level, fetched by `vale sync`) and `BasedOnStyles` (per-glob, activates) are separate keys — a style only lints files once it's in both. This is the step people forget. -- A rule scoped to `text.frontmatter.` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Confirmed against Vale 3.15.2 with a deliberately-bad fixture: a `>` folded block scalar, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars each yield 0 findings and exit 0, silently and with no error; a `|` literal block scalar spanning the same 2+ lines lints normally and exits 1. Do not assume `|` and `>` behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. +- A style in `BasedOnStyles` that is neither built-in nor a directory under `StylesPath` fails hard, not silently: `E100 [loadStyles]`, exit 2, nothing linted. +- `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. +- Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. +- `.vale.ini` order is enforced, not stylistic: put core settings first, then `[formats]`, then glob sections. A core setting (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) written below a `[glob]` header is a hard error — `E201 ... 'StylesPath' is a core option; it should be defined above any syntax-specific options`, exit 2, nothing linted. `Packages` is the exception, and the worse one: below a glob header it is accepted with no error, then ignored — `vale sync` reports `Synced 0 package(s)` and downloads nothing. +- An `.mdx` file covered by one of your globs takes the whole run down unless `[formats]` maps it. Vale 3.15.2 has no built-in MDX support: unmapped, it shells out to an external `mdx2vast` binary, and with that absent from `PATH` the invocation dies on `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2 — every other file in the same command goes unlinted, with no output of its own. Default to the mapping — a `[formats]` section holding `mdx = md`, above the glob sections — which needs nothing installed; `npm install -g mdx2vast` is the alternative. The choice also inverts the inline-suppression syntax `vale-run` uses, so record which one the config took. +- A rule scoped to `text.frontmatter.` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -36,7 +35,9 @@ metadata: [*.md] BasedOnStyles = Vale ``` - `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`) — no download needed, it always works. + `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`): no `Packages` entry and no `vale sync`. It still needs the `StylesPath` directory to exist — declare `StylesPath = styles` without creating `styles/` and even a `Vale`-only config dies with `E201 ... The path '...' does not exist`, exit 2. That is why the previous step creates the directory. + + **Settings in a glob section only apply to files matching that glob.** `BasedOnStyles` under `[*.md]` governs `.md` and nothing else: a `.mdx`, `.rst` or `.txt` in the same tree has no style active, is skipped without being counted, and a run over only such files reports `0 files` and exits 0 — indistinguishable from clean. Give every extension you mean to lint a glob that covers it. - [ ] **Add third-party styles** (optional) by declaring them in `Packages`, then activating them in the same or another glob's `BasedOnStyles`: ```ini Packages = Google, write-good @@ -47,7 +48,7 @@ metadata: - [ ] **Sync**: run `vale sync` to download everything listed in `Packages` into `StylesPath`. - [ ] **Verify activation**: confirm every style named in `Packages` also appears in at least one glob's `BasedOnStyles` — an unreferenced package downloads but never lints anything. -For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields), read `references/configuration-reference.md`. +For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields) and the verified style-resolution matrix — which error each misconfiguration raises, and the two that exit 0 while linting nothing — read `references/configuration-reference.md`. ## Custom styles @@ -59,4 +60,4 @@ styles/ └── NoJargon.yml ``` -Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. See `references/configuration-reference.md` for the full rule header field table. +Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. If a rule needs a field beyond those two, read `references/configuration-reference.md` for the full rule header field table. diff --git a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md index d2bca35..b572d41 100644 --- a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md @@ -2,6 +2,7 @@ topic: configuration-reference source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Core Settings @@ -24,6 +25,10 @@ Map an unrecognized extension onto a supported one so Vale lints it with the rig mdx = md ``` +`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Take the mapping: it is this skill's recommended default, because it needs nothing installed, and it is the branch `vale-run` assumes when it documents inline suppressions. Install `mdx2vast` (`npm install -g mdx2vast`) only when something else in the toolchain already needs the native MDX parser. + +The choice also decides the inline-suppression syntax, and it is inverted between the two: mapped to `md`, `.mdx` takes Markdown's ``; native, it takes `{/* vale off */}`. `vale-run`'s `references/troubleshooting.md` carries the verified matrix. + ## Vocabularies Reference a named vocabulary (a folder of accept/reject word lists under `StylesPath`) via `Vocab`, then apply styles per glob: @@ -68,11 +73,41 @@ Individual rule YAML files (under a style's directory) support these header fiel The underlying functions a rule's `extends` field can reference: `existence`, `substitution`, `occurrence`, `repetition`, `consistency`, `conditional`, `capitalization`, `metric`, `spelling`, `sequence`, `script`. -## Built-in Style +## Style Resolution -Vale ships with a default `Vale` style containing four rules, usable without `vale sync`: +Only *package* styles need fetching. A style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no `vale sync`; the same is true of the built-in `Vale` style, which ships with the binary and contains four rules: - `Vale.Spelling` — spell-checks against Hunspell-compatible dictionaries in `/config/dictionaries`. - `Vale.Terms` — enforces the project's accepted vocabulary terms. - `Vale.Avoid` — enforces the project's rejected vocabulary terms. - `Vale.Repetition` — flags repeated words (e.g. "the the"). + +`Packages` (top-level, what `vale sync` downloads) and `BasedOnStyles` (per-glob, what activates) are separate keys: a style lints a file only once it is in both. Every row below reproduced against Vale 3.15.2 (slug `house-vale-3-15-2-repro`): + +| Configuration | Result | +|---|---| +| `BasedOnStyles` names a style with no directory under `StylesPath`, not built-in | `E100 [loadStyles] Runtime error` — `style 'X' does not exist on StylesPath`, exit 2 | +| `StylesPath` directory itself absent, even with only `Vale` active | `E201 Invalid value` — `The path '...' does not exist`, exit 2 | +| `vale sync` with a name in `BasedOnStyles` but not `Packages` | `SUCCESS Synced 0 package(s)`, exit 0, nothing downloaded — the next lint repeats the `E100` | +| `vale sync` with the name added to `Packages` | package lands under `StylesPath`, exit 0; lint then loads it | +| Style in `Packages` and synced, but in no glob's `BasedOnStyles` | 0 findings, exit 0 — downloads, never lints, indistinguishable from a clean run | +| `BasedOnStyles` names an *empty* directory under `StylesPath` | 0 findings, exit 0 — loads and lints nothing; `vale sync` never produces this state | +| Built-in `Vale`, or a style's YAML committed under `StylesPath` | lints immediately, no `Packages` entry, no sync | +| Core option (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) below a `[glob]` header | `E201 Invalid value` — `'X' is a core option; it should be defined above any syntax-specific options ([...])`, exit 2 | +| `Packages` below a `[glob]` header | no error, exit unaffected — parsed as a per-glob rule toggle (`SChecks: {"*.md": {"Packages": false}}` in `ls-config`), so `vale sync` reports `Synced 0 package(s)` and downloads nothing | + +## Frontmatter Scopes + +House-verified behaviour, not documented on vale.sh — reproduced locally against Vale 3.15.2 (slug `house-vale-3-15-2-repro`). + +A rule scoped to `text.frontmatter.` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Multi-line forms spanning 2+ lines: + +| Frontmatter value form | Result | +|---|---| +| Single physical line (control) | Lints, exits 1 | +| `\|` literal block scalar | Lints, exits 1 | +| `>` folded block scalar | 0 findings, exits 0 | +| Plain (unquoted) continuation lines | 0 findings, exits 0 | +| Single- or double-quoted multi-line scalar | 0 findings, exits 0 | + +The silent cases produce no error of any kind, so a passing run is indistinguishable from a clean one. Do not assume a literal block scalar and a folded one behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. diff --git a/plugins/lint/.apm/skills/vale-config/references/sources.md b/plugins/lint/.apm/skills/vale-config/references/sources.md index 854fe15..c4a918e 100644 --- a/plugins/lint/.apm/skills/vale-config/references/sources.md +++ b/plugins/lint/.apm/skills/vale-config/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/configuration-reference.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, the `E201` / exit-2 failure when a core option is written below a `[glob]` header (with `Packages` as the silent exception), and the `text.frontmatter.` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/configuration-reference.md +- **Status:** `extracted` diff --git a/plugins/lint/.apm/skills/vale-run/README.md b/plugins/lint/.apm/skills/vale-run/README.md index 8572456..459c68a 100644 --- a/plugins/lint/.apm/skills/vale-run/README.md +++ b/plugins/lint/.apm/skills/vale-run/README.md @@ -19,5 +19,5 @@ Describe what you want to lint and how (human-readable output, CI/JSON output, f | File | Purpose | |------|---------| | `SKILL.md` | Core invocation, key flags, output format guidance, false-positive triage order | -| `references/troubleshooting.md` | Inline suppression syntax, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | +| `references/troubleshooting.md` | Load when a rule appears not to apply, when writing inline suppression or spelling-ignore syntax, or when wiring Vale into pre-commit: resolved-config diagnostic (`vale ls-config`), format-specific suppression markup, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | | `references/sources.md` | Research provenance | diff --git a/plugins/lint/.apm/skills/vale-run/SKILL.md b/plugins/lint/.apm/skills/vale-run/SKILL.md index 11f443a..5baae10 100644 --- a/plugins/lint/.apm/skills/vale-run/SKILL.md +++ b/plugins/lint/.apm/skills/vale-run/SKILL.md @@ -1,28 +1,22 @@ --- name: vale-run description: > - Use when running Vale (a prose/style linter) against files or directories in an - already-configured project — one that already has a .vale.ini — and interpreting - or reporting its results: choosing an output format for humans vs. CI, filtering - by severity, handling Vale's exit codes in scripts, or resolving common runtime - issues like false positives and unexpected CI failures. Use even if the user - doesn't say "vale" explicitly, e.g. "lint the docs", "check prose style", "run - the style linter", "why is CI failing on the docs check". Do not use when the - project has no .vale.ini yet, or needs styles installed/configured — that's the - vale-config skill. + Use when running Vale (a prose/style linter) on a project that already has a + .vale.ini and acting on its output — even when the user does not say "Vale", + as in "lint the docs", "check prose style", or "why is CI failing on the docs + check". Not setting up Vale config or styles -> `vale-config`. metadata: - version: "0.1.1" + version: "0.1.3" category: lint source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas -- Vale's exit code is driven by `error`-level alerts only. `warning` and `suggestion` alerts are reported but still exit `0`. `MinAlertLevel` and `--minAlertLevel` control display, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the single most common way a Vale gate silently passes everything. -- `vale ls-config` prints the fully-resolved, currently active configuration as JSON — the fastest way to check why a rule "isn't applying" is what's actually active, not what's written in `.vale.ini`. -- Inline suppression syntax is format-specific: Markdown uses HTML comments `` / ``, MDX uses `{/* vale off */}` / `{/* vale on */}`, Org mode uses `# vale off` / `# vale on`. The MDX form does nothing in a plain `.md` file — the alert still fires. Don't assume one syntax works across formats. -- Before calling the `vale` binary directly, check whether the target repo documents its own wrapper script for Vale (look in its README, CONTRIBUTING docs, pre-commit config, or a `scripts/` directory). Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter fields — and calling bare `vale` in a repo that has such a wrapper silently skips whatever the wrapper works around. If a wrapper is documented, invoke it with the same arguments instead of calling `vale` directly; otherwise fall back to the default below. +- Vale's exit code keys off `error`-level alerts only — `warning` and `suggestion` alerts print and still exit `0`, and `MinAlertLevel`/`--minAlertLevel` filter what is displayed, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the most common way a Vale gate silently passes everything. +- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a `text.frontmatter.` scope that silently stops matching multi-line values (`>` folded scalars, plain continuation lines and quoted multi-line scalars all go unmatched; a `|` literal block scalar still works) — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. ## Running vale @@ -41,18 +35,22 @@ Key flags: | `--no-exit` | Suppresses the nonzero exit that `error`-level alerts would otherwise cause; a no-op when no rule is `error`-level. Use in CI stages that should surface lint output without hard-failing the build. | | `--ignore-syntax` | Treats input as plain text, skipping format-aware parsing — use when a file's syntax-aware parser produces noisy or wrong results. | -`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, that's a sign `vale sync` hasn't been run yet, not a `vale-run` problem. +`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. `ls-config` resolves config files, styles and `StylesPath` search paths; it never enumerates rules, so it cannot tell you a given rule is live. Prefer `--output=JSON` whenever the caller (a script, a CI step, another agent) needs to act on individual alerts rather than just get a pass/fail signal — `CLI` and `line` are for humans reading the terminal. ## Fixing false positives +Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md`: the form follows the parser the config picks, not the file extension, and the wrong form suppresses nothing while Vale reports no error. + +`.mdx` is the trap — the two parsers take opposite forms, so read `.vale.ini` first. Under `[formats] mdx = md` (what `vale-config` recommends) it is Markdown: `` suppresses, `{/* vale off */}` does not. Without that mapping Vale takes the native MDX path, which needs the external `mdx2vast` binary (`npm install -g mdx2vast`); missing, the whole invocation dies — `E100 [lintMDX] ... mdx2vast not found`, exit 2 — leaving every other file in the run unlinted too. + Scope the fix as narrowly as possible, in this order: 1. **Mentioning banned phrasing rather than using it**: wrap it in backticks or a fenced code block. Vale skips code spans and fences, so no suppression is needed at all. Try this before any suppression markup. 2. **One-off**: inline-suppress the specific text run with the format's `vale off`/`vale on` markup. -3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (e.g. `` ... `= YES`), rather than the whole rule. -4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression. +3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (in Markdown, e.g. `` ... `= YES`), rather than the whole rule. +4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression — and put the listed file at the `StylesPath` root, because an `ignore` path that resolves nowhere is a silent no-op (`references/troubleshooting.md`). Never disable a rule project-wide to fix one false positive — editing `.vale.ini`/`BasedOnStyles` is vale-config's job, and it silences the rule everywhere, not just the false-positive case. @@ -60,4 +58,4 @@ If output looks wrong because Vale mis-parsed a file's format, rerun with `--ign For CI that fails solely because Vale returned non-zero on `error`-level alerts — not because the content is wrong for that pipeline stage — add `--no-exit` rather than disabling the rule. If the failing alerts are warnings or suggestions, Vale is not what failed the build; look elsewhere. -If setting up Vale as a pre-commit hook or need the full inline-suppression/spelling-ignore syntax reference, read `references/troubleshooting.md`. +If a rule appears not to apply, if you need the spelling-ignore syntax, if a CI failure still needs diagnosing, or if setting Vale up as a pre-commit hook, read `references/troubleshooting.md`. diff --git a/plugins/lint/.apm/skills/vale-run/references/sources.md b/plugins/lint/.apm/skills/vale-run/references/sources.md index 279ef95..196d43a 100644 --- a/plugins/lint/.apm/skills/vale-run/references/sources.md +++ b/plugins/lint/.apm/skills/vale-run/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/troubleshooting.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing or documents it wrongly: `.mdx` has no built-in support and needs either `[formats] mdx = md` or an external `mdx2vast` binary (absent, the whole invocation exits 2 with `E100 [lintMDX]`), the inline-suppression form inverts between those two configurations, the `spelling` check's `ignore` paths resolve against `StylesPath` or the working directory but never against the rule file's own directory and fail silently when they resolve nowhere, `ls-config` reports styles and paths but never rules, and the `text.frontmatter.` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/troubleshooting.md +- **Status:** `extracted` diff --git a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md index 093280f..a00a777 100644 --- a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md +++ b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md @@ -1,20 +1,77 @@ --- source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- # Vale troubleshooting reference +## Why a rule isn't applying + +`vale ls-config` prints the fully-resolved, currently active configuration as JSON. Check that +before rereading `.vale.ini` — what is written in the config file is not necessarily what is +active, and the resolved output is the fastest way to see which config files, styles and +`StylesPath` directories a run actually loaded. + +It stops at styles. It does not enumerate rules, and neither does any other Vale 3.15.2 +subcommand — `ls-config`, `ls-dirs`, `ls-vars` and `ls-metrics` were each checked and none +names the rule. Against a config +whose custom rule was demonstrably firing on the target file, `ls-config` reported +`"SBaseStyles": {"*.md": ["MyStyle"]}` and the two `StylesPath` search paths, but +`"Checks": null`, `"SChecks": {"*.md": {}}` and `"RuleToLevel": {}` — the firing rule's own name +appeared nowhere in the output. So `ls-config` answers "is this style loaded, and from where", +not "is this rule live". For the latter, run Vale over a small fixture that should trigger the +rule and see whether it alerts. + ## Inline suppression syntax by format -Markdown uses HTML comments — the MDX `{/* */}` form does not suppress anything in a plain `.md` file: +### Prerequisite: `.mdx` needs a decision before it lints at all + +Vale 3.15.2 has no built-in MDX support. If `.vale.ini` does **not** map the extension, Vale takes +the native MDX path and shells out to an external `mdx2vast` binary. Without it on `PATH` the run +dies before linting anything: + +``` +$ vale . # doc.md and doc.mdx both present, no [formats] mapping +E100 [lintMDX] Runtime error + +mdx2vast not found + +Execution stopped with code 1. +$ echo $? +2 +``` + +That is the whole invocation, not just the `.mdx` file — the `.md` alongside it produced no output +either. Fix it one of two ways, and the choice is not cosmetic because it also decides the +suppression syntax: + +| `.vale.ini` | Prerequisite | Parser | Suppression form that works | +|---|---|---|---| +| `[formats]` maps `mdx = md` (what `vale-config` recommends) | none | Markdown | `` | +| no `mdx` mapping (native MDX) | `npm install -g mdx2vast` | MDX | `{/* vale off */}` | + +Key the markup to that config row, never to the file extension. Verified against Vale 3.15.2, same +three fixtures under each config: + +| File | Mapped `mdx = md` | Native MDX (`mdx2vast` installed) | +|---|---|---| +| no suppression (control) | alert, exit 1 | alert, exit 1 | +| `` | suppressed, exit 0 | `E100 ... failed to parse MDX: Unexpected character` `` `!` ``, exit 2 | +| `{/* vale off */}` | **not suppressed**, exit 1 | suppressed, exit 0 | + +Under the mapping the JSX comment is worse than inert: it is linted as prose, so +`{/* vale Vale.Repetition = NO */}` produced three alerts where the un-suppressed file produced +one — its own markup tripped the rule twice more. + +Markdown, and `.mdx` mapped onto it — HTML comments: ```markdown This text will be ignored. ``` -MDX: +Native MDX only (no `[formats]` mapping, `mdx2vast` on `PATH`): ```mdx {/* vale off */} This text will be ignored. @@ -39,7 +96,8 @@ This is some text ACT test ``` -MDX: +Native MDX only — under `[formats] mdx = md` an `.mdx` file takes the Markdown form above, and +this one silences nothing: ```mdx {/* vale Style.Redundancy["ACT test","OTHER"] = NO */} This is some text ACT test @@ -59,6 +117,36 @@ ignore: - ignore2.txt ``` +**Where the file goes, and why a wrong answer is invisible.** Each entry resolves against the +`StylesPath` root, or against the working directory `vale` is invoked from. It does **not** resolve +against the rule file's own directory — which is the natural reading of the YAML above, since the +path sits inside the rule, and it is wrong. Verified against Vale 3.15.2 across four fresh trees, +each with the same rule and the same unknown word: + +| Where `ignore1.txt` was placed | Result | +|---|---| +| `/ignore1.txt` | word ignored, exit 0 | +| `./ignore1.txt` in the directory `vale` runs from | word ignored, exit 0 | +| `/