refactor(skills): retrofit the corpus to the ADR-0020 context contract (#129)
Retrofits all 39 skills to ADR-0020's description/body context contract, then fixes what six rounds of independent review found in that retrofit — including four ways the hot gate itself failed open. Closes #99, #107, #108, #110, #111, #114, #115, #120. ## The retrofit (waves 1-5) | | Start | Now | |---|---|---| | Description FAILs (>400 chars) | 26 | **0** | | Body FAILs (>900 words, body-only) | 9 | **0** | | Dangling routing targets | 2 | **0** | | `Kyberforge.CompositionNote` | 10 | **0** | | Preload tax | 21,005 chars | **~10,500** | Under the 12,000-char success criterion. Per-wave detail is on #99. ## The review fixes **The gate failed open four ways, three of them found after the retrofit shipped.** An unrecognised follower token made a dangling target vanish. A skill directory with no `SKILL.md` resolved as a valid target, so a commit could be green locally and red in a fresh clone — three existing fixtures were relying on that, one of which made the install-leak A/B pass vacuously. Then the free-standing `/name` sweep turned out to be gated on the sentence carrying a boundary marker, so route notation in any other sentence was invisible — not an ERROR, not a SUGGESTION, not an INFO — which left the documented "`/name` always blocks" promise false from a second direction. All four fixed and pinned. **Two checks were silently not running.** `validate-provenance.sh` checks 7-8 were dead across nine skills. Waking them exposed a deeper problem: they assume `Research doc:` names a source index, but 30 of 121 entries point at topic content documents, so every new check-7 INFO was a false positive and check 8 was saved from a false-FAIL flood only by an *unannounced* skip. Checks 7/8 are now scoped to source indexes and every skip announces itself (#121). **The retrofit's own anti-goal, four times.** ADR-0020 warns that a blunt gate gets satisfied by deleting content rather than relocating it. `diagnose` and `skill-audit` relocated prose and then read it unconditionally; `prototype` and `vale-config` deleted rules outright that survived nowhere. All four addressed. ## Verification - `bash tests/run-tests.sh --strict` — 24 suites, 0 skipped, 0 failed - `bash tests/run-bats.sh` — 325 tests, 0 failures - `pre-commit run --all-files` — 17/17 - `pre-commit run --hook-stage pre-push --all-files` — 16/16, with `apm marketplace check` and `apm pack --check-clean` run against the remote, not skipped - `scripts/skill-size-check.sh` over all 39 skills — rc 0, 0 ERROR/FAIL, SUGGESTION-only - Preload tax measured at **10,498 chars**, max description 390 — both inside budget - Every new test proven non-vacuous by a deliberate mutation of the behaviour it covers **Per-commit sync, stated accurately:** the ten commits from the latest review round each pass `check-plugin-content-sync` in isolation, verified by checking each out in a detached worktree with a clean between. The earlier gitea window (`dfacf05..bedbd1d`, nine commits) does **not** — its mirror was regenerated in one batch at `bbc7300`. An earlier revision of this description claimed the property held for every commit; it does not, and a bisect through that window lands on a red commit. **Squash-merge** to collapse it, or accept that this range is not bisectable. ## Version bump Six plugins and the catalog take a **patch**, not a minor. The branch is **89 commits — 40 `fix` / 30 `refactor` / 12 `docs` / 5 `chore` / 2 `test` — zero `feat`, zero `!`, zero `BREAKING CHANGE`** — and adds no skill, agent, command or hook. (Two earlier revisions of this section cited a stale histogram, most recently 78 commits; the figures above are measured at HEAD.) Both rules this repo ships (`forge/references/version-bump.md`, landing in this PR, and `git-commits/references/conventional-commits-spec.md`) make that a patch, and the catalog set is unchanged at 7 entries. Not settled by that: four published files were removed from the installed tree, three moved, and `caveman` gained `disable-model-invocation`, retiring its old triggers. Under a strict reading those are major-class and currently ship under `refactor:` with no marker. Whether the deployed skill surface is a public contract is written down nowhere — worth deciding, but it outlives this PR. ## Deliberately not in scope #112 (cherry-pick ownership, now resolved in favour of `git-commits`), #113 (`rtk git` normalisation), #116 (research fan-out), #101 (audit-skill merge), #122 (non-spec skill-root files), #123 (no PRD producer) stay open. #117 is the one worth reading: the contract's remedy is to move prose into `references/`, which is exactly where neither the size gate nor Vale looks — and the blind spot is wider than #117 currently records, since there is no root `.vale.ini` at all, so every ADR, `CONTEXT.md` and `README.md` is unlinted too. That blind spot let this branch carry two `level: error` `Kyberforge.SentenceOpenerThereIs` violations into `references/` files it created — `provider-adapter-author/references/provider-matrix.md:31` and `agent-audit/references/finding-criteria.md:95`. Both are reworded in `afadaae`, confirmed by routing each file through the audit's own `vale-wrap.sh` (1 error each before, 0 after). Five further occurrences sit in `references/` files already on `main`; those are the pre-existing corpus and stay with #117, which is the real fix. Also unfixed and not this PR's: `apm install` appends a duplicate `SessionStart` entry to `.claude/settings.json`, so a fresh clone cannot get pre-push green without an edit AGENTS.md warns against. Reproduces identically on `main`. Co-authored-by: Defame1297 <gitea@rkdr.net> Reviewed-on: https://git.dev.rkdr.net/Defame1297/holocron/pulls/129 Co-authored-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net> Co-committed-by: Claude Code AI - Gitea MCP <claude@noreply.git.dev.rkdr.net>
This commit was merged in pull request #129.
This commit is contained in:
@@ -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/<name>/` 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 <agent-file>
|
||||
bash scripts/validate-provenance.sh <agent-file>
|
||||
scripts/vale-wrap.sh <agent-file> [<counterpart-file>]
|
||||
bash scripts/vale-wrap.sh <agent-file> [<counterpart-file>]
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -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/<name>.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. |
|
||||
|
||||
@@ -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 `<skill>`
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 `<skill>`
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<name>.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'(?<!`)FILL IN:[^`\n]')
|
||||
|
||||
# The trailing character class used to be CONSUMING — `[^`\n]` — so a
|
||||
# `FILL IN:` at end of line matched nothing and escaped checks 1 and 5
|
||||
# entirely. `- **Description:** FILL IN:` is the most likely spelling of a
|
||||
# half-written entry, and it was the one spelling the placeholder gate could
|
||||
# not see. The exclusion it was really expressing is "not inside backticks",
|
||||
# which a lookahead states without eating a character.
|
||||
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:(?!`)')
|
||||
|
||||
def parse_frontmatter(content):
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
@@ -130,7 +291,56 @@ def parse_source_keys(fm):
|
||||
def parse_h2_slugs(content):
|
||||
return re.findall(r'^## (.+)$', content, re.MULTILINE)
|
||||
|
||||
# ===== BEGIN SHARED CONTRIBUTING-FILES PARSER =====
|
||||
# ONE parser, embedded VERBATIM in two scripts:
|
||||
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh
|
||||
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh
|
||||
# The block between these markers must stay byte-identical in both. It is
|
||||
# copied rather than imported because a cache-installed plugin's scripts cannot
|
||||
# read files outside their own plugin directory, so there is no single file both
|
||||
# can share — the same constraint that forces the ADR-0020 boundary resolver to
|
||||
# be duplicated across three scripts. Edit one copy, then paste it over the
|
||||
# other.
|
||||
#
|
||||
# tests/test-adr0020-contract.sh hashes both copies and fails on drift. Before
|
||||
# it did, the agent-audit copy's docstring merely ASSERTED the two were
|
||||
# "behaviourally identical" and nothing checked it — which is how the two
|
||||
# already-diverged spellings of the bullet loop went unnoticed.
|
||||
#
|
||||
# Requires: re (imported by the host script).
|
||||
|
||||
|
||||
def parse_contributing_files(content, slug):
|
||||
"""Find the Contributing files for a given slug H2 in content.
|
||||
|
||||
Both authored forms are accepted, because both are in use across the
|
||||
corpus and only recognising the first silently skipped the contributing-
|
||||
file checks on every sources.md written the other way:
|
||||
|
||||
- **Contributing files:** SKILL.md, references/a.md
|
||||
|
||||
**Contributing files:**
|
||||
- SKILL.md (what this source contributed)
|
||||
- references/a.md (what this source contributed)
|
||||
|
||||
Returns a list of paths with any trailing parenthetical note stripped.
|
||||
Note the bullet form's notes may themselves contain commas, so the list
|
||||
is built per bullet rather than by splitting the joined value.
|
||||
|
||||
The three return values are NOT interchangeable, and callers depend on
|
||||
the distinction:
|
||||
|
||||
[path, ...] the entry names contributing files
|
||||
[] the entry EXPLICITLY records "(none)"
|
||||
None the entry says nothing this parser can read
|
||||
|
||||
Only an explicit "(none)" yields []. A "Contributing files:" heading
|
||||
followed by a numbered list, by `*` bullets, or by prose parses nothing
|
||||
and returns None, never [] — a caller reads [] as a deliberate "no
|
||||
contributing files" record and SKIPS its check on that basis, so a parse
|
||||
failure returning [] would silently disable the check instead of leaving
|
||||
the unreadable entry exposed to it.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
|
||||
re.MULTILINE | re.DOTALL
|
||||
@@ -139,72 +349,153 @@ def parse_contributing_files(content, slug):
|
||||
if not m:
|
||||
return None
|
||||
block = m.group(1)
|
||||
|
||||
def strip_note(entry):
|
||||
# "references/a.md (why)" -> "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:** <comma-separated paths>', 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",
|
||||
|
||||
@@ -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 <thing> -> <skill-name>`. Without that, diagnose's process chain
|
||||
# "fix -> regression-test" reads as a route to `regression-test`.
|
||||
# `Not <thing> -> <skill-name>`. 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
|
||||
# `<plugin>:` 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)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
||||
MARKED_TARGET = (r"(?:`/?(%s)`|(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S))"
|
||||
% (NAME_ANY, NAME_ANY))
|
||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||
@@ -548,12 +654,53 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||
ARROW_MARKED = re.compile(r"(?:->|→)\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"(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S)" % NAME_ANY, re.I)
|
||||
NOTATION_ARROW = 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 <thing> -> <name>`.
|
||||
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'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
|
||||
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])')
|
||||
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
|
||||
|
||||
# The token that may follow a route target without turning it into a compound
|
||||
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
|
||||
@@ -634,11 +789,26 @@ def _notation(text, start, arrow):
|
||||
|
||||
|
||||
def _add(out, text, name, start, end, strict=None, arrow=False):
|
||||
"""Record one target as (name, may_dangle, notation).
|
||||
|
||||
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
|
||||
The header above promises that route notation "always blocks", and for the
|
||||
`/name` form that was false: `-> 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 <thing> -> <name>`. 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():
|
||||
|
||||
@@ -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" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Research doc:** (none)
|
||||
**Contributing files:**
|
||||
* .apm/agents/ghost.agent.md (asterisk bullets are not the bullet form)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output --partial "INFO"
|
||||
assert_output --partial "Contributing-file checks skipped for 'my-source' — the Contributing files block could not be parsed"
|
||||
}
|
||||
|
||||
@test "INFO: an entry with no Contributing files field at all is reported, not skipped silently" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output --partial "INFO"
|
||||
assert_output --partial "Contributing-file checks skipped for 'my-source' — the Contributing files block could not be parsed"
|
||||
}
|
||||
|
||||
@test "checks 3 and 4 skipped silently: an explicit '(none)' emits no INFO" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
make_sources_md "$root" "my-source" "(none — not used directly)"
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "checks 3 and 4 still run: a parseable Contributing files list is not diverted to the INFO" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
make_sources_md "$root" "my-source" ".apm/agents/nonexistent.agent.md"
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "FAIL"
|
||||
assert_output --partial "Contributing file '.apm/agents/nonexistent.agent.md' does not exist"
|
||||
refute_output --partial "could not be parsed"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The INFO tier itself: kind-aware printing and a kind-aware exit code
|
||||
#
|
||||
# INFO is new here — before it, findings was a 5-tuple and print_findings
|
||||
# stamped every entry FAIL. The two cases below pin the tier rather than any
|
||||
# one check: an INFO must print under the INFO prefix and leave the exit code
|
||||
# at 0, and a real FAIL must keep printing under the FAIL prefix and still exit
|
||||
# non-zero even when an INFO is sitting in the same findings list.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "INFO tier: an INFO alone prints as INFO with a Note and does not set a failing exit code" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output --partial "INFO Contributing-file checks skipped for 'my-source'"
|
||||
assert_output --partial "Note:"
|
||||
refute_output --partial "FAIL"
|
||||
}
|
||||
|
||||
@test "FAIL tier: a genuine FAIL alongside an INFO still prints as FAIL and exits non-zero" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Contributing files:** .apm/agents/nonexistent.agent.md
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
|
||||
## ghost-source
|
||||
|
||||
- **URL:** https://example.com/ghost-source
|
||||
- **Description:** Another test source.
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "FAIL Contributing file '.apm/agents/nonexistent.agent.md' does not exist"
|
||||
assert_output --partial "Why:"
|
||||
assert_output --partial "INFO Contributing-file checks skipped for 'ghost-source'"
|
||||
assert_output --partial "Note:"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The exit-2 tier, and its boundary with the silent exit 0
|
||||
#
|
||||
# Ported from the skill-audit sibling, which had already split usage and
|
||||
# environment errors (exit 2) away from findings (exit 1). SKILL.md tells the
|
||||
# auditor to surface a non-zero exit, so a usage error leaving exit 1 with
|
||||
# nothing on stdout was indistinguishable from a clean-but-failing run.
|
||||
#
|
||||
# The reconciliation this script needs and the sibling does not: "the walk-up
|
||||
# found no type:-bearing apm.yml" is NOT bad input. It is a verdict about a
|
||||
# real, readable agent file — user or project scope, where plugin-scope
|
||||
# provenance does not apply — and scripts/check-scope-walkup-sync.sh fixture 6
|
||||
# pins it as exit 0 with empty output. Every exit-2 gate is therefore decided
|
||||
# from the ARGUMENT ALONE, before the walk-up runs, so the two can never
|
||||
# collide. The two tests at the end of this block assert both halves.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "exit 2: no arguments is a usage error, not a finding" {
|
||||
run bash "$SCRIPT"
|
||||
[ "$status" -eq 2 ]
|
||||
assert_output --partial "agent-file is required"
|
||||
}
|
||||
|
||||
@test "exit 2: a second positional argument is rejected instead of silently dropped" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_clean_agent "$root"
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" --some-typo
|
||||
[ "$status" -eq 2 ]
|
||||
assert_output --partial "expected exactly one argument"
|
||||
}
|
||||
|
||||
@test "exit 2: a nonexistent path is an error, not a silent pass" {
|
||||
run bash "$SCRIPT" "$TMPDIR/no-such-agent.agent.md"
|
||||
[ "$status" -eq 2 ]
|
||||
assert_output --partial "no such file"
|
||||
}
|
||||
|
||||
@test "exit 2: a directory is not an agent file" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
run bash "$SCRIPT" "$root/.apm/agents"
|
||||
[ "$status" -eq 2 ]
|
||||
assert_output --partial "not a regular file"
|
||||
}
|
||||
|
||||
@test "exit 2: an unrecognized extension is rejected before the walk-up runs" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
echo "not an agent" > "$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" <<EOF
|
||||
name: outer-package
|
||||
version: 0.1.0
|
||||
type: skill
|
||||
EOF
|
||||
local fake_home="$dir/fakehome"
|
||||
mkdir -p "$fake_home/.apm/agents"
|
||||
cat > "$fake_home/.apm/agents/my-agent.agent.md" <<EOF
|
||||
---
|
||||
name: my-agent
|
||||
description: A valid agent description.
|
||||
source_keys:
|
||||
- my-source
|
||||
---
|
||||
|
||||
You are a test agent.
|
||||
EOF
|
||||
run env HOME="$fake_home" bash "$SCRIPT" "$fake_home/.apm/agents/my-agent.agent.md"
|
||||
[ "$status" -eq 0 ]
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "reconciliation: a nonexistent path inside a non-plugin-scope tree exits 2, not the old silent 0" {
|
||||
# The other half. Before the exit-2 tier, a typo'd path anywhere outside a
|
||||
# package took the not-plugin-scope exit and reported a silent pass, so the
|
||||
# typo and a clean agent produced identical output and identical status.
|
||||
local fake_home="$TMPDIR/plainhome"
|
||||
mkdir -p "$fake_home/.apm/agents"
|
||||
run env HOME="$fake_home" bash "$SCRIPT" "$fake_home/.apm/agents/typo.agent.md"
|
||||
[ "$status" -eq 2 ]
|
||||
assert_output --partial "no such file"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PLACEHOLDER_RE: the trailing character class was CONSUMING
|
||||
#
|
||||
# `(?<!\`)FILL IN:[^\`\n]` required a character after the colon, so a `FILL IN:`
|
||||
# at end of line matched nothing and escaped checks 1 and 5 entirely — and
|
||||
# `- **Description:** FILL IN:` is the most likely spelling of a half-written
|
||||
# entry. The lookahead states the same exclusion without eating a character.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "FAIL: a FILL IN: placeholder at end of line is caught, not skipped" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** FILL IN:
|
||||
- **Contributing files:** .apm/agents/my-agent.agent.md
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "Unfilled FILL IN: placeholder"
|
||||
}
|
||||
|
||||
@test "FAIL: a Research doc value that is a bare end-of-line FILL IN: is caught" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Contributing files:** .apm/agents/my-agent.agent.md
|
||||
- **Research doc:** FILL IN:
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "Research doc field is empty or placeholder"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encoding, read side: read_text() pins UTF-8 and strips a BOM
|
||||
#
|
||||
# The old code used bare open() calls inheriting locale.getpreferredencoding(),
|
||||
# which is ASCII under LC_ALL=C, and wrapped exactly one of them in
|
||||
# `except Exception: return []` — so an unreadable agent file was reported as
|
||||
# having no source_keys and therefore as CLEAN. The other call sites had no
|
||||
# handler at all and died with a traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "FAIL: an undecodable agent file is reported, not swallowed into a clean pass" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
printf '\xff\xfe---\nname: my-agent\n---\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 "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" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source — with an em dash.
|
||||
- **Contributing files:** .apm/agents/ghost.agent.md
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "Contributing file '.apm/agents/ghost.agent.md' does not exist"
|
||||
refute_output --partial "Traceback"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8')
|
||||
#
|
||||
# Pinning only the reads moved the crash from the read to the WRITE. Every
|
||||
# finding this script prints contains an em dash, so under LC_ALL=C
|
||||
# print_findings() died with UnicodeEncodeError after every check had already
|
||||
# run — losing the whole report at the last step.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "under LC_ALL=C the findings report is printed, not lost to a UnicodeEncodeError" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
make_sources_md "$root" "my-source" ".apm/agents/ghost.agent.md"
|
||||
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_failure
|
||||
assert_output --partial "FAIL Contributing file '.apm/agents/ghost.agent.md' does not exist"
|
||||
assert_output --partial "Why:"
|
||||
refute_output --partial "UnicodeEncodeError"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Half-validated entries announced instead of passing silently
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "INFO: a duplicated '## slug' says only the first block was checked" {
|
||||
# Every per-slug parser locates its block with pattern.search(), so a slug
|
||||
# written twice resolves to the FIRST block every time: the second block's
|
||||
# fields are never validated, and the entry looked fully checked.
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Contributing files:** (none)
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/dup
|
||||
- **Description:** A duplicate entry.
|
||||
- **Contributing files:** .apm/agents/ghost.agent.md
|
||||
- **Research doc:** (none)
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output --partial "Duplicate '## my-source' entry in sources.md"
|
||||
# The second block's ghost contributing file is genuinely never checked —
|
||||
# the INFO is what makes that visible rather than a silent half-pass.
|
||||
refute_output --partial "does not exist"
|
||||
}
|
||||
|
||||
@test "INFO: a second '- **Research doc:**' line in one entry is announced, not ignored" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
make_agent_with_source_keys "$root"
|
||||
cat > "$root/sources.md" <<EOF
|
||||
# Sources
|
||||
|
||||
## my-source
|
||||
|
||||
- **URL:** https://example.com/my-source
|
||||
- **Description:** A test source.
|
||||
- **Contributing files:** (none)
|
||||
- **Research doc:** (none)
|
||||
- **Research doc:** docs/research/added-later.md
|
||||
- **Status:** \`extracted\`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
|
||||
assert_success
|
||||
assert_output --partial "Multiple '- **Research doc:**' lines for 'my-source'"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding dedup
|
||||
#
|
||||
# The agent file is read once for its own source_keys and again as a
|
||||
# contributing file, so an unreadable one produced the identical finding twice.
|
||||
# Distinct findings about the same file still both appear.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "the same unreadable file reached by two checks is reported once, not twice" {
|
||||
local root="$TMPDIR/package"
|
||||
make_package "$root"
|
||||
printf '\xff\xfe---\nname: my-agent\n---\n' > "$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 ]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 `<root>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<name>-<version>`) — 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
|
||||
|
||||
@@ -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 <name>`
|
||||
- `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 <name>` run with a positional name argument, from inside a directory already named `<name>`, creates a wrongly-nested `<name>/<name>/` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 `<package-path>` gained a new `<artifact-type>` (`<artifact-name>`). 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 `<package-name>` version from X.Y.Z to X.Y.Z to reflect the new `<artifact-name>`."
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 `<package-path>` gained a new `<artifact-type>` (`<artifact-name>`). 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 `<package-name>` version from `<old>` to `<new>` to reflect the
|
||||
new `<artifact-name>`."
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 <skill-dir>
|
||||
bash scripts/validate-provenance.sh <skill-dir>
|
||||
scripts/vale-wrap.sh <skill-dir>/SKILL.md
|
||||
bash scripts/vale-wrap.sh <skill-dir>/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
|
||||
|
||||
|
||||
@@ -31,7 +31,16 @@ Include content the agent lacks:
|
||||
Move to `references/`, behind an explicit "If X, read `references/<file>.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:<the git plugin>/.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.
|
||||
|
||||
@@ -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 <thing> -> <skill-name>.` 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.
|
||||
|
||||
@@ -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/<plugin>/skills/<other>/`
|
||||
and its APM-native equivalent `.apm/skills/<other>/`) 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/<plugin>/skills/<other>/` and its APM-native equivalent `.apm/skills/<other>/`), 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'(?<!`)FILL IN:[^`\n]')
|
||||
# The trailing character class used to be CONSUMING — `[^`\n]` — so a
|
||||
# `FILL IN:` at end of line matched nothing and escaped checks 1 and 6
|
||||
# entirely. `- **Description:** FILL IN:` is the most likely spelling of a
|
||||
# half-written entry, and it was the one spelling the placeholder gate could
|
||||
# not see. The exclusion it was really expressing is "not inside backticks",
|
||||
# which a lookahead states without eating a character.
|
||||
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:(?!`)')
|
||||
|
||||
|
||||
# --- Input ----------------------------------------------------------------
|
||||
# Ported from validate.sh, 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 a references file then aborted the run with a bare UnicodeDecodeError
|
||||
# traceback, or, at the one call site that wrapped its read in `except
|
||||
# Exception: return False`, 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 SKILL.md: 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))
|
||||
|
||||
def parse_frontmatter(content):
|
||||
"""Return (frontmatter_str, body_str) or (None, content) if no frontmatter."""
|
||||
@@ -89,13 +209,84 @@ def parse_source_keys(fm):
|
||||
in_metadata = False
|
||||
return keys
|
||||
|
||||
# An explicit `source_keys: []` — top-level or under metadata: — is a
|
||||
# DECLARATION that the file is house-authored and has no external source.
|
||||
# parse_source_keys() returns [] both for that and for a file with no
|
||||
# source_keys key at all, so the two are indistinguishable downstream and
|
||||
# check 3 emitted the same INFO for each (#111). That left no honest way to
|
||||
# record "this file has no external source": the only ways to silence the INFO
|
||||
# were to invent a slug or borrow an unrelated one, both false provenance
|
||||
# claims that then have to be maintained in sources.md as well. A bare
|
||||
# `source_keys:` with nothing after it is NOT accepted here — that reads as a
|
||||
# truncated or half-written entry, not a decision.
|
||||
#
|
||||
# The indent is pinned to the two positions parse_source_keys() actually reads
|
||||
# — column 0, or two spaces under `metadata:`. A permissive `^\s*` matched a
|
||||
# `source_keys: []` nested at ANY depth under an unrelated key, which
|
||||
# parse_source_keys() never reads, so a stray nested key silenced the check-3
|
||||
# INFO for a file that had declared nothing.
|
||||
EMPTY_SOURCE_KEYS_RE = re.compile(r'^(?: )?source_keys:\s*\[\s*\]\s*$')
|
||||
|
||||
def declares_empty_source_keys(fm):
|
||||
"""True when frontmatter carries an explicit, empty `source_keys: []`."""
|
||||
if fm is None:
|
||||
return False
|
||||
return any(EMPTY_SOURCE_KEYS_RE.match(line) for line in fm.splitlines())
|
||||
|
||||
def parse_h2_slugs(content):
|
||||
"""Return list of H2 heading values from a markdown file."""
|
||||
return re.findall(r'^## (.+)$', content, re.MULTILINE)
|
||||
|
||||
# ===== BEGIN SHARED CONTRIBUTING-FILES PARSER =====
|
||||
# ONE parser, embedded VERBATIM in two scripts:
|
||||
# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh
|
||||
# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh
|
||||
# The block between these markers must stay byte-identical in both. It is
|
||||
# copied rather than imported because a cache-installed plugin's scripts cannot
|
||||
# read files outside their own plugin directory, so there is no single file both
|
||||
# can share — the same constraint that forces the ADR-0020 boundary resolver to
|
||||
# be duplicated across three scripts. Edit one copy, then paste it over the
|
||||
# other.
|
||||
#
|
||||
# tests/test-adr0020-contract.sh hashes both copies and fails on drift. Before
|
||||
# it did, the agent-audit copy's docstring merely ASSERTED the two were
|
||||
# "behaviourally identical" and nothing checked it — which is how the two
|
||||
# already-diverged spellings of the bullet loop went unnoticed.
|
||||
#
|
||||
# Requires: re (imported by the host script).
|
||||
|
||||
|
||||
def parse_contributing_files(content, slug):
|
||||
"""Find the Contributing files value for a given slug H2 in content."""
|
||||
# Find the H2 block for slug, then look for Contributing files line
|
||||
"""Find the Contributing files for a given slug H2 in content.
|
||||
|
||||
Both authored forms are accepted, because both are in use across the
|
||||
corpus and only recognising the first silently skipped the contributing-
|
||||
file checks on every sources.md written the other way:
|
||||
|
||||
- **Contributing files:** SKILL.md, references/a.md
|
||||
|
||||
**Contributing files:**
|
||||
- SKILL.md (what this source contributed)
|
||||
- references/a.md (what this source contributed)
|
||||
|
||||
Returns a list of paths with any trailing parenthetical note stripped.
|
||||
Note the bullet form's notes may themselves contain commas, so the list
|
||||
is built per bullet rather than by splitting the joined value.
|
||||
|
||||
The three return values are NOT interchangeable, and callers depend on
|
||||
the distinction:
|
||||
|
||||
[path, ...] the entry names contributing files
|
||||
[] the entry EXPLICITLY records "(none)"
|
||||
None the entry says nothing this parser can read
|
||||
|
||||
Only an explicit "(none)" yields []. A "Contributing files:" heading
|
||||
followed by a numbered list, by `*` bullets, or by prose parses nothing
|
||||
and returns None, never [] — a caller reads [] as a deliberate "no
|
||||
contributing files" record and SKIPS its check on that basis, so a parse
|
||||
failure returning [] would silently disable the check instead of leaving
|
||||
the unreadable entry exposed to it.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)',
|
||||
re.MULTILINE | re.DOTALL
|
||||
@@ -104,28 +295,113 @@ def parse_contributing_files(content, slug):
|
||||
if not m:
|
||||
return None
|
||||
block = m.group(1)
|
||||
|
||||
def strip_note(entry):
|
||||
# "references/a.md (why)" -> "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/<subdir>/ 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:** <comma-separated paths>', 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:
|
||||
|
||||
@@ -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 <thing> -> <skill-name>`. Without that, diagnose's process chain
|
||||
# "fix -> regression-test" reads as a route to `regression-test`.
|
||||
# `Not <thing> -> <skill-name>`. 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
|
||||
# `<plugin>:` 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)`|(?<![\w./*-])/(%s)\b)" % (NAME_ANY, NAME_ANY)
|
||||
MARKED_TARGET = (r"(?:`/?(%s)`|(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S))"
|
||||
% (NAME_ANY, NAME_ANY))
|
||||
ANY_TARGET = r"(?:%s|(%s)\b)" % (MARKED_TARGET, NAME_HYPH)
|
||||
ROUTE_MARKED = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, MARKED_TARGET), re.I)
|
||||
ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET), re.I)
|
||||
@@ -474,12 +580,53 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
|
||||
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
|
||||
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
|
||||
ARROW_MARKED = re.compile(r"(?:->|→)\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"(?<![\w./*-])/(%s)(?![\w-])(?!/|\.\S)" % NAME_ANY, re.I)
|
||||
NOTATION_ARROW = 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 <thing> -> <name>`.
|
||||
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'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
|
||||
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])')
|
||||
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
|
||||
|
||||
# The token that may follow a route target without turning it into a compound
|
||||
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
|
||||
@@ -560,11 +715,26 @@ def _notation(text, start, arrow):
|
||||
|
||||
|
||||
def _add(out, text, name, start, end, strict=None, arrow=False):
|
||||
"""Record one target as (name, may_dangle, notation).
|
||||
|
||||
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
|
||||
The header above promises that route notation "always blocks", and for the
|
||||
`/name` form that was false: `-> 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 <thing> -> <name>`. 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 <thing> -> <name>`.
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,13 +70,23 @@ PY
|
||||
# this repo's live skills. Echoes the subject skill's directory.
|
||||
#
|
||||
# <root>/plugins/fixture-plugin/.apm/skills/<subject>/SKILL.md
|
||||
# <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/
|
||||
# <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/SKILL.md
|
||||
# <root>/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" <<EOF
|
||||
---
|
||||
name: locale-skill
|
||||
description: A valid skill description that is well within the limit.
|
||||
---
|
||||
|
||||
## Step 1
|
||||
|
||||
Do the thing.
|
||||
EOF
|
||||
run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$dir"
|
||||
assert_success
|
||||
assert_output --partial "description has no boundary clause"
|
||||
refute_output --partial "UnicodeEncodeError"
|
||||
refute_output --partial "Traceback"
|
||||
}
|
||||
|
||||
@@ -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 `<root>/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 <thing> -> first-skill. Not <other thing> -> second-skill.`, never
|
||||
`Not <thing> -> 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 <condition>, read `references/<file>.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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user