From 5f984ce34aaa2214c213970e7f77ff2f7ff5378e Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 11:54:18 +0000 Subject: [PATCH 01/89] chore(apm): refresh lock after SessionStart update The SessionStart hook found six packages behind the remote default branch and ran apm update. Committing the resulting lock separately so it does not sit in the ADR-0020 retrofit diff. --- apm.lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apm.lock.yaml b/apm.lock.yaml index b236fe6..ca28793 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,12 +1,12 @@ lockfile_version: '1' -generated_at: '2026-08-17T06:44:18.931217+00:00' +generated_at: '2026-08-30T11:38:46.419301+00:00' apm_version: 0.28.0 dependencies: - repo_url: Defame1297/holocron name: bin host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.1.3 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.1.5 virtual_path: plugins/bin is_virtual: true package_type: marketplace_plugin @@ -81,13 +81,13 @@ dependencies: .claude/skills/triage/SKILL.md: sha256:b819f0285e4e5814ac6472d0217f07dc9f23d2fdae3ebb0dd31d98360d8ac029 .claude/skills/write-docs/SKILL.md: sha256:0d06d0f6836a67532497ea61bd5a1294a8d80f529bf1ffb5c4b1fdc72e9cb51a .claude/skills/zoom-out/SKILL.md: sha256:2a6894c7f9b1c9c55f451c625a834c4f377e217b623a85280e55db5fe9cacf48 - content_hash: sha256:7acfaa806ae8379fe6351441ce8f973ac3ff8a02deb4a5e145968203d1176ef8 + content_hash: sha256:d7cca4972651b241a5450679289fdb6f0e3a37aef9dff0498cb4b3340b048781 declared_license: MIT exec_status: gated_pending_approval - repo_url: Defame1297/holocron name: core host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.1.1 virtual_path: plugins/core is_virtual: true @@ -134,8 +134,8 @@ dependencies: - repo_url: Defame1297/holocron name: git host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.3.3 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.3.5 virtual_path: plugins/git is_virtual: true package_type: marketplace_plugin @@ -236,13 +236,13 @@ dependencies: .claude/skills/pc-run/references/README.md: sha256:ad42b9013dbd44b8c4515c15317c5e9e6b235bc656ad5a03e037eb4bc6f8782c .claude/skills/pc-run/references/failure-patterns.md: sha256:59d913c001483c3770d269fdf5e83cf1d7578e0ae879928a1404bf93bb395994 .claude/skills/pc-run/references/sources.md: sha256:7ca8b7106e7ee5c2c7d1fbd3dc9e0d2a89c18eba79145cbab55bc8e39eef9087 - content_hash: sha256:6d0c8fcec32d8fb8321b539cc567c4241342cfa33e5d0b6f66697d2f8b784fdd + content_hash: sha256:8c47bd35572c331f1a1b61a8be159d9690a1bf286958a85826fde01939e3007b declared_license: MIT - repo_url: Defame1297/holocron name: gitea host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae - version: 1.3.4 + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 + version: 1.3.6 virtual_path: plugins/gitea is_virtual: true package_type: marketplace_plugin @@ -313,10 +313,10 @@ dependencies: .claude/skills/gitea-labels-milestones/references/labels.md: sha256:be8ff5ce4dbfb31dd2bdcc425a769dca94887c5c8acd47c78e1e21ccfc24dcba .claude/skills/gitea-labels-milestones/references/milestones.md: sha256:ff1f1b0c8ecb6967c940d1ca8bebf2a2c7995414936f8ac7f5ff23d1dc01ac3f .claude/skills/gitea-labels-milestones/references/sources.md: sha256:a1a1d3381dfb0dc8f331be0b3ab49f28e94b992fb4809ca09be91ce33047dd1e - .claude/skills/gitea-prs/README.md: sha256:0346388f642641c56cef82ba1ee576a187d048eee66fab839a17417c81122cbc + .claude/skills/gitea-prs/README.md: sha256:31b6ad46379feee8ac7d837030f93ec04be20b7d11021b295dc6d27b4340a05b .claude/skills/gitea-prs/SKILL.md: sha256:bf70832f3d97064c9816d680390da84c256a971d7804317e1814b4ee6a622b4d .claude/skills/gitea-prs/references/merging.md: sha256:5f8b55b1a729f122ee265fb5da69369fc27434a13dd02fc112801f945d2c9d19 - .claude/skills/gitea-prs/references/pull-requests.md: sha256:631874a7cddfcf678a2beffa5578fa7848bcef36be1251fa13fb0ccc543bcaf7 + .claude/skills/gitea-prs/references/pull-requests.md: sha256:d6cefd0cf270f8d844536311e67bfa45cd07f024aa0a649de1938af306ae7109 .claude/skills/gitea-prs/references/reviews.md: sha256:1089cf45a75a562bbb29de6538848c4f4bd627d5a7e36bf9b2ab6496f45f962b .claude/skills/gitea-prs/references/sources.md: sha256:ac48a3018443b56acb5c3d55d6c04e62103f5bac71b8185e2bd085fb7e165431 .claude/skills/gitea-releases/README.md: sha256:102625066c34c7b5a16e25b7506a87f876e9b123b334f5ffba73ab1523a57ed7 @@ -327,12 +327,12 @@ dependencies: .claude/skills/gitea-workflow/README.md: sha256:338f79d0b3c25741848fce841a5094ef27cbc7e4a6228404557b2d9594df68fa .claude/skills/gitea-workflow/SKILL.md: sha256:a8266ceb335c56eb68cc09b3f81690c52c4dada9f25b48c3a46ac46b0ad3b761 .claude/skills/gitea-workflow/references/sources.md: sha256:171d3a5a36cb9d637302516501a1e9fc7a4bd058968dc9244412c335aa2de68b - content_hash: sha256:8149467df78678963dbb140aa254f606a53491f3cb7e5d97a1211be426e7892e + content_hash: sha256:16d5b4462c3f4a506c1c74172d4d67bf0dacc4218676de4e8dfe551b2f31a3f8 declared_license: MIT - repo_url: Defame1297/holocron name: kyberforge host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.6.0 virtual_path: plugins/kyberforge is_virtual: true @@ -530,7 +530,7 @@ dependencies: - repo_url: Defame1297/holocron name: lint host: git.dev.rkdr.net - resolved_commit: 9385c77ac71a67db2b8fe9c3783af9e0fbbf2eae + resolved_commit: 0e91a3ae66d1b0439b9c0ef4b6e88ca6f2659473 version: 1.1.6 virtual_path: plugins/lint is_virtual: true @@ -1873,7 +1873,7 @@ deployments: owners: - git.dev.rkdr.net/Defame1297/holocron/plugins/gitea active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/gitea - content_hash: sha256:0346388f642641c56cef82ba1ee576a187d048eee66fab839a17417c81122cbc + content_hash: sha256:31b6ad46379feee8ac7d837030f93ec04be20b7d11021b295dc6d27b4340a05b - kind: project-relative target: claude value: .claude/skills/gitea-prs/SKILL.md @@ -1900,7 +1900,7 @@ deployments: owners: - git.dev.rkdr.net/Defame1297/holocron/plugins/gitea active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/gitea - content_hash: sha256:631874a7cddfcf678a2beffa5578fa7848bcef36be1251fa13fb0ccc543bcaf7 + content_hash: sha256:d6cefd0cf270f8d844536311e67bfa45cd07f024aa0a649de1938af306ae7109 - kind: project-relative target: claude value: .claude/skills/gitea-prs/references/reviews.md -- 2.43.0 From ef3e981eacc490a029102ddd9c3cb168bb5f9b87 Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 11:54:26 +0000 Subject: [PATCH 02/89] docs(context): record that hand-invocation blocks skill-to-skill routing The Hand-invoked skill entry covered listing visibility and preload tax but not invocability. Measured: disable-model-invocation: true hard-blocks the Skill tool, not just the model-visible listing, so a `Call `x`` step in another skill's body stops working the moment x takes the flag. ADR-0020's 'Invocation as a design axis' verified only the visibility and slash directions. Without this half, marking grill-with-docs hand-invoked -- one of issue #99's four named candidates -- would silently break forge Step 1, which calls it, plus three other inbound routes. Refs #99 --- CONTEXT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index fa9dfbf..3e5d366 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,7 +35,9 @@ _Avoid_: router body, thin body **Hand-invoked skill**: A skill reached only by typing its slash command, declared `disable-model-invocation: true`. The host withholds it from the model-visible listing entirely, so it pays no preload tax and its description -becomes human-facing text. Exemplar: `zoom-out`. +becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other skill can route to +a hand-invoked skill** — a `Call \`x\`` step in another skill's body stops working the moment `x` +takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`. _Avoid_: manual skill, disabled skill **Delegation discipline**: -- 2.43.0 From dfacf051a8d0d28a37cae44faa644ffe86f2db8c Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:06:37 +0000 Subject: [PATCH 03/89] refactor(gitea-releases): retrofit to the ADR-0020 context contract Description 500 -> 211 chars, body 676 -> 462 words, Gotchas 5 entries/41% of body -> 3/21%. Clears the description FAIL and both Gotchas suggestions. Cut the second trigger register (the re-quoted user phrasings), the doubled capability enumeration across releases and tags, and the gitea-issues/gitea-prs boundary, which defended against nothing -- neither was going to win a release request. Kept the indirect trigger and the one real near-miss, gitea-branches. Releases and tags are one flow, not two: 'delete the release and its tag' is a single invocation that takes both branches, which disqualifies mutual exclusivity, so no dispatch split. Two Gotchas moved to references/ behind explicit triggers; one deleted as a paraphrase of the step below it. Refs #99 --- .../gitea/.apm/skills/gitea-releases/SKILL.md | 29 ++++++++----------- .../gitea-releases/references/sources.md | 3 +- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md index 7bcc8e9..a573c4c 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md @@ -2,12 +2,9 @@ name: gitea-releases description: > - Use when managing Gitea releases and tags for a repository: listing, creating, or deleting - releases (with draft/prerelease flags and release notes), and listing, creating, or deleting the - underlying git tags. Use even if the user doesn't say "release" explicitly — "cut a v1.2.0", - "publish a prerelease", "tag this commit", or "what's the latest release" all apply. Do not use - for git branch or commit history operations (use gitea-branches) or for issue/PR management (use - gitea-issues / gitea-prs). + Use when managing Gitea releases or the git tags underneath them — list, get, + create, or delete either — even when the user does not say "release" or + "Gitea". Not branches or commit history -> `gitea-branches`. metadata: category: gitea @@ -20,11 +17,9 @@ metadata: ## Gotchas -- **`delete_release` takes a numeric `id`, never a tag name.** `delete_tag` is the mirror opposite — it takes the `tag_name` string, never a numeric id. These two tools are asymmetric on purpose; passing a tag name to `delete_release` or a numeric id to `delete_tag` fails. Always resolve the numeric release id via `list_releases` or `get_release` first if you only have a tag name in hand. -- **Deleting a release does not delete its tag.** They are separate destructive operations against separate resources — a release is a wrapper (title, notes, draft/prerelease flags, assets) around a tag, not the tag itself. If the intent is to remove both, call `delete_release` and `delete_tag` separately. -- **`list_releases`/`list_tags` default to `per_page: 20`**, unlike most other gitea-mcp tools which default to 30. The MCP layer does no auto-pagination — to get a complete result set, loop `page` upward until a page returns fewer than `per_page` results. -- **`is_draft`/`is_pre_release` are explicit booleans the caller sets on `create_release` — never inferred from `tag_name`.** Note the input param is `is_draft`, which maps to the `draft` field on the *response* object (see Dispatch table below and `references/call-signatures.md`) — `draft` is never a valid input key. Practitioner convention (per the `tea` CLI) uses `-beta`/`-rc` suffixes for prereleases (e.g. `v2.0.0-beta.1`), but Gitea does not enforce or infer this from the tag string. If the user names a tag that looks like a prerelease, set `is_pre_release: true` explicitly rather than assuming the flag is redundant with the name. -- **Tag names are conventionally semver, `v`-prefixed** (`v1.2.0`, `v2.0.0-beta.1`), but this is a practitioner convention, not a Gitea constraint — don't reject or rewrite a caller-supplied tag name that doesn't follow it. +- **Deleting a release never deletes its tag, and deleting a tag never deletes the release wrapping it.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. +- **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. +- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. ## Dispatch table @@ -40,13 +35,13 @@ metadata: | Create tag | `create_tag` | `owner`, `repo`, `tag_name` | `target`, `message` | | Delete tag | `delete_tag` | `owner`, `repo`, `tag_name` | — | -`target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. See `references/call-signatures.md` for response shapes. +`target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. ## Workflow -- [ ] **Creating a release:** Call `create_release` directly with `tag_name` + `target` + `title` — Gitea is assumed to create the underlying tag automatically if `tag_name` doesn't already exist (this is plausible behavior inferred from the API shape, not directly confirmed in the research docs), so a separate `create_tag` call is only needed when you want to tag a commit without wrapping it in a release yet. Verify the tag exists afterward if this matters to the caller. Set `is_pre_release`/`is_draft` explicitly per the Gotchas above; don't leave them to default inference. -- [ ] **Deleting a release safely:** Resolve the numeric id first — call `list_releases` (paginate if needed, see Gotchas) or `get_release` if the id is already known, find the entry matching the target `tag_name`, then call `delete_release` with that `id`. Never pass `tag_name` to `delete_release`. -- [ ] **Deleting a tag along with its release:** Delete the release first (frees the id lookup), then call `delete_tag` with the `tag_name` separately — confirm both are intended before proceeding, since each is an independent irreversible operation. -- [ ] **Listing every page:** If the caller needs all releases or tags (not just the first page), loop `page: 1, 2, 3...` until a response has fewer than `per_page` entries. +- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, and `title`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. +- [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. +- [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. +- [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. -If exact response field shapes or additional conventions are needed, read `references/call-signatures.md` and `references/conventions.md`. +If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/sources.md b/plugins/gitea/.apm/skills/gitea-releases/references/sources.md index 2b67a9f..cffb8b9 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/sources.md @@ -42,7 +42,6 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/workflow-conventions.md (Release and tag conventions section) **Contributing files:** -- SKILL.md (Gotchas — semver tag naming) -- references/conventions.md +- references/conventions.md (semver tag naming, release-notes sourcing) **Status:** `extracted` -- 2.43.0 From ee812699a49813f076770b761cc5e8c5330a3ab0 Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:08:38 +0000 Subject: [PATCH 04/89] refactor(gitea-branches): retrofit to the ADR-0020 context contract Description 688 -> 282 chars, body 435 -> 293 words, Gotchas 5 entries/54% of body -> 3/23.5%. Clears the description FAIL and the Gotchas suggestion. Cut the second trigger register (six re-quoted user phrasings) and the capability enumeration; both moved to a new Boundaries section in the skill's own README. Kept all three boundary clauses -- git-branches, git-history and gitea-prs each defend a real activation steal, and gitea-prs is now the only guard on the branch/PR collision in either direction since gitea-prs's own retrofit narrowed its boundary to issues. Written as one arrow per target: the resolver extracts only the first name per arrow clause, so conjoined targets go unchecked. Two Gotchas deleted -- one paraphrased the step below it (its non-obvious half, the get_me/list_my_repos token-scope block, was folded into that step), the other is carried in full by references/branches.md:40-52. Repoint two reference pointers the rename broke: branches.md and commits.md named Gotchas by titles this retrofit changed. Now named by stable descriptors. Refs #99 --- .../.apm/skills/gitea-branches/README.md | 12 ++++++++ .../gitea/.apm/skills/gitea-branches/SKILL.md | 30 +++++++------------ .../gitea-branches/references/branches.md | 2 +- .../gitea-branches/references/commits.md | 2 +- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-branches/README.md b/plugins/gitea/.apm/skills/gitea-branches/README.md index 1be8bcc..3a99f72 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/README.md +++ b/plugins/gitea/.apm/skills/gitea-branches/README.md @@ -10,6 +10,18 @@ history (list commits, get a single commit's full detail) against a Gitea reposi pagination conventions specific to Gitea's API (e.g. refusing to delete a protected branch without explicit confirmation, and treating unexpected 404s as possible masked 403s). +## Boundaries + +This skill operates on the Gitea server via the MCP tools, never on your local checkout. Branch +and commit-history work against the working copy belongs to `git-branches` and `git-history`. +Branch references that only exist relative to a pull request — a PR's head or base branch, and +cross-repo fork PR heads in particular — belong to `gitea-prs`; `list_branches` cannot see a fork's +head at all. + +The skill triggers on phrasings like "list branches", "create a branch", "delete a branch", +"what commits are on this branch", "show commit ", and "what changed in that commit", even +when the user does not say "Gitea", as long as the repo's remote is a Gitea instance. + ## Before you start Requires a Gitea MCP server configured with a token that has `write:repository` scope. This is diff --git a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md index 654ebfc..fc112ff 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md @@ -2,22 +2,16 @@ name: gitea-branches description: > - Use when managing Gitea repository branches — listing, creating, or deleting - branches — or inspecting commit history within a Gitea repo: listing commits - (optionally filtered by branch or file path) or getting full detail for a - single commit by SHA. Triggers on "list branches", "create a branch", - "delete a branch", "what commits are on this branch", "show commit ", - "what changed in that commit" — even if the user doesn't say "Gitea" - explicitly, as long as the repo's remote is a Gitea instance. Do not use for - local git branch/commit operations on your working copy (use git-branches or - git-history) or for PR-side branch references like cross-repo fork PR heads - (use gitea-prs). + Use when listing, creating, or deleting branches in a Gitea repository, or + reading its commit history — even when the user does not say "Gitea". Not a + local working copy's branches -> `git-branches`. Not local history -> + `git-history`. Not a PR's head or base branch -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. metadata: category: integration - version: "0.1.1" + version: "0.1.2" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -28,11 +22,9 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git ## Gotchas -- **Never delete a protected branch (`main`/`master` by name, or `protected: true` from `list_branches`) without explicit confirmation.** `delete_branch` is a direct API call, not a local `git push` — there is no client-side force-push guard protecting it. Name-matching `main`/`master` is a convenient default but not authoritative — a repo can protect a differently-named default branch. When in doubt, call `list_branches` first and check `protected` on the target; treat deletion of any protected branch as a hard refusal unless the user explicitly confirms in the conversation. -- **404 may actually mean 403.** Gitea hides permission errors as not-found to avoid leaking resource existence. If any of these five tools returns 404 unexpectedly, check token scope (see `references/branches.md` / `references/commits.md`) before concluding the branch or commit doesn't exist. -- **Pagination is manual.** `list_branches` and `list_commits` return one page at a time — no auto-pagination in the MCP layer. When you need a complete list, iterate `page: 1, 2, ...` until the returned count is less than `per_page`. -- **Owner/repo always come from the git remote, never from `get_me`.** Resolve them via `git remote get-url origin` (Step 1 below). `get_me`/`list_my_repos` are blocked under the token scopes this skill assumes. -- **`create_branch`'s source is `old_branch`, not "wherever gitea-mcp feels like."** Omitting `old_branch` forks from the repo's server-side default branch — not necessarily the branch you're currently working on locally. If you want to branch from your current checkout, pass `old_branch` explicitly. +- **404 often means 403.** Gitea masks permission errors as not-found; on an unexpected one, check token scope before reporting a branch or commit missing. +- **Nothing auto-paginates.** `list_branches` and `list_commits` return one page; iterate `page` until the returned count is below `per_page`. +- **`delete_branch` has no force-push guard.** Check `protected` from `list_branches` first and require explicit confirmation — a protected branch need not be named `main`. ## Step 1 — Resolve owner and repo @@ -42,7 +34,7 @@ Before any tool call, extract `owner` and `repo` from the git remote: git remote get-url origin ``` -If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." ## Step 2 — Dispatch @@ -54,7 +46,7 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r | `/gitea-branches commits [on ] [touching ]` | List commit history | | `/gitea-branches commit ` | Get full detail for one commit | -For branch operations (list/create/delete), read `references/branches.md`. +For branch operations (list/create/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. For commit operations (list/get), read `references/commits.md`. ## Step 3 — Report @@ -63,4 +55,4 @@ For reads: display branches as name + protected flag; display commits as SHA (sh For writes (create/delete): confirm the action taken, the branch name, and (for create) the base it forked from. -For errors: surface the HTTP code and message. If a 404 is unexpected, re-check token scope per the Gotchas above before reporting "not found" to the user. +For errors: surface the HTTP code and message, applying the 404 gotcha above before reporting "not found" to the user. diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md index b94754d..ff4e239 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md @@ -65,7 +65,7 @@ A branch name collision returns `409 Conflict`. delete_branch owner: repo: branch: ``` -Before calling this, see the hard-refusal Gotcha in SKILL.md. If the target branch's name isn't +Before calling this, see the `delete_branch` Gotcha in SKILL.md. If the target branch's name isn't obviously a scratch/feature branch, call `list_branches` first and check `protected` on the matching entry — name-matching `main`/`master` alone isn't authoritative, since a repo can protect a differently-named default branch. Confirm explicitly with the user before deleting anything diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md index fddeff5..19f8b3c 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md @@ -42,7 +42,7 @@ Dispatch defaults: **Response:** one object per commit: `sha`, `html_url`, `created`, `message` (when available), `author` (`{name, email, date}`, when available). -Paginate per the manual-pagination Gotcha in SKILL.md if you need more than one page of history. +Paginate per the pagination Gotcha in SKILL.md if you need more than one page of history. ## `get_commit` -- 2.43.0 From d5954d3d991021cef6123d8bd8454e46a63d781b Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:09:52 +0000 Subject: [PATCH 05/89] refactor(gitea-files): retrofit to the ADR-0020 context contract Description 787 -> 263 chars, body 922 -> 302 words, Gotchas 9 entries/69% of body -> 3/24.8%. Clears both size FAILs and both Gotchas suggestions. Deleted the second trigger register outright -- ~300 chars re-quoting the same six verbs as user phrasings, which ADR-0020 names this skill for specifically. Split the body on the read/write boundary: one invocation cannot both read and write, so a dispatch table is mandatory. Six operations collapse into two flow files rather than six -- create/update/delete share one tool pair and one SHA-first lifecycle whose preamble would otherwise be triplicated, and the three read tools share ref selection plus a 'neither listing is a SHA source' comparison that only exists between them. references/examples.md removed; all eight of its content blocks and all nine named parameters carry into references/writing.md, verified against git HEAD. Two deltas are corrections: the repo tree is now ruled out as a SHA source, and reusing a SHA captured earlier in the conversation is now forbidden. Four Gotchas relocated to the flow file that needs them; two promoted to gates (SHA-as-concurrency-token opens writing.md; owner/repo became ## Inputs). Refs #99 --- .../gitea/.apm/skills/gitea-files/README.md | 3 +- .../gitea/.apm/skills/gitea-files/SKILL.md | 50 +++++------- .../skills/gitea-files/references/examples.md | 65 ---------------- .../skills/gitea-files/references/reading.md | 46 +++++++++++ .../skills/gitea-files/references/sources.md | 14 ++-- .../skills/gitea-files/references/writing.md | 76 +++++++++++++++++++ 6 files changed, 153 insertions(+), 101 deletions(-) delete mode 100644 plugins/gitea/.apm/skills/gitea-files/references/examples.md create mode 100644 plugins/gitea/.apm/skills/gitea-files/references/reading.md create mode 100644 plugins/gitea/.apm/skills/gitea-files/references/writing.md diff --git a/plugins/gitea/.apm/skills/gitea-files/README.md b/plugins/gitea/.apm/skills/gitea-files/README.md index 52f560d..333e899 100644 --- a/plugins/gitea/.apm/skills/gitea-files/README.md +++ b/plugins/gitea/.apm/skills/gitea-files/README.md @@ -19,5 +19,6 @@ Describe the file task: read a file or directory, walk a tree, create/update a f | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/examples.md` | Canonical call sequences: branch + file + PR, recovering a missing SHA before an update, deleting a file | +| `references/reading.md` | Loaded for the read flow: the three read tools, `ref`/`tree_sha` selection, tree pagination, and why a listing is not a SHA source | +| `references/writing.md` | Loaded for the write flow: the SHA-first create/update/delete sequences, `new_branch_name`, the worked branch + file + PR sequence, and failed-write triage | | `references/sources.md` | Research sources backing the SHA/concurrency and direct-commit-vs-PR guidance | diff --git a/plugins/gitea/.apm/skills/gitea-files/SKILL.md b/plugins/gitea/.apm/skills/gitea-files/SKILL.md index f687f70..18e47cb 100644 --- a/plugins/gitea/.apm/skills/gitea-files/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-files/SKILL.md @@ -2,15 +2,9 @@ name: gitea-files description: > - Use when reading or writing individual files or directory trees in a Gitea repository via the - Gitea MCP server: reading a file's contents, listing a directory, walking a full repository - tree, creating a new file, updating an existing file, or deleting a file. Triggers on "read this - file from the repo", "what's in this directory", "show me the repo tree", "create/update a file - in Gitea", "commit this file to the branch", "delete this file from the repo" — even when the - user doesn't say "Gitea" explicitly, as long as the target is a Gitea-hosted repository. Do not - use for local filesystem file operations (use Read/Write/Edit), for branch or commit history - (use gitea-branches), or for opening a pull request around a file change (use gitea-prs after - the file write completes here). + Use when reading or writing files in a Gitea repository rather than on the local filesystem — + read, list, walk the tree, create, update, or delete — even when the user does not say "Gitea". + Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue @@ -28,29 +22,27 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Gotchas -- **A 404 from any read call may actually be a 403 in disguise.** `get_file_contents`, `get_dir_contents`, and `get_repository_tree` all gate on `write:repository` scope, not just read access — some Gitea endpoints return 404 instead of 403 when the token's scope is insufficient, to avoid leaking whether the resource exists. If a read fails with 404 on a path you're confident is correct, check the token's configured scopes before concluding the file or directory doesn't exist. -- **SHA is the concurrency token for every write — and it lives at the top level of `get_file_contents`'s response, not nested under `content`.** `create_or_update_file` without `sha` is always treated as a *create*: if the path already exists, Gitea returns HTTP 409. `delete_file` has no optional path at all — omitting `sha` returns HTTP 422. The safe sequence for any update or delete is always: call `get_file_contents` first, read the top-level `sha` field, then pass that exact value to the write call. Never guess or reuse a stale SHA — a mismatched SHA is rejected the same as a missing one. -- **A write can also fail because the branch requires signed commits — a separate failure mode from a bad SHA.** `create_or_update_file` and `delete_file` create commits server-side via a bare API token call with no 2FA/PGP context. If the target branch's protection rule requires signed commits, Gitea rejects the write outright — surfaced as a generic 403 or 422, not an error naming "signed commit required," and reads against that same branch keep succeeding right up until you try to write. When a write fails without a clean 409 (missing/stale SHA) or 404 (bad path) explanation, check whether the branch's protection rule requires signed commits before assuming the SHA is wrong and retrying. -- **A large `create_or_update_file` payload can hit a reverse-proxy 413 that has nothing to do with Gitea.** `content` is base64-encoded, which inflates the payload ~33% over the raw file size; a 413 is commonly a reverse-proxy body-size limit in front of the Gitea instance, not a Gitea-side rejection. No amount of retrying, or changing the SHA, path, or branch, will fix it — it needs the proxy's config raised, which is outside this skill's or the calling agent's control. Surface that distinction to the user instead of retrying the same call. -- **`get_dir_contents` and `get_repository_tree` are not SHA sources for a specific file's write.** `get_dir_contents` entries carry no `sha` at all. `get_repository_tree` entries do carry a `sha` (a blob/tree hash), but fetching it means an extra round trip with no content — `get_file_contents` is the canonical path since it returns the decoded content and the write-ready `sha` in one call. -- **`owner` and `repo` are always caller-supplied inputs, never resolved here.** This skill doesn't infer them from a git remote. If invoked directly by a human, ask for them if not stated. If invoked by `gitea-workflow` or an orchestrating agent, expect them to already be resolved and passed in. -- **Direct commits to a branch are a first-class action, not a workaround.** Gitea's own web UI defaults to editing files directly against a branch — `create_or_update_file`/`delete_file` used that way is normal, not an API escape hatch to avoid. The SHA-currency requirement above is the actual risk to manage, not the act of committing directly. -- **`ref` (reads) vs. `branch_name` (writes) are different parameters for the same concept.** `get_file_contents`, `get_dir_contents`, and `get_repository_tree` (as `tree_sha`) all accept a branch name, tag, or commit SHA to select what to read. `create_or_update_file` and `delete_file` instead take `branch_name` — the branch the commit lands on. Don't conflate the two when chaining a read into a write. -- **Content is base64.** `create_or_update_file`'s `content` parameter is base64-encoded file content, not raw text — encode before calling. `get_file_contents`'s response content is likewise base64-encoded (decode after reading), unless `withLines: true` is passed for a numbered-line view. +- **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. +- **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. +- **`content` is base64 both ways.** Encode before a write, decode after a read; `withLines: true` returns numbered lines. -## Reading +## Inputs -- **Single file:** `get_file_contents(owner, repo, ref, path)`. Pass `withLines: true` only when you need line numbers for referencing specific lines (e.g. quoting a snippet back to the user); omit it for a normal content fetch. -- **One directory level:** `get_dir_contents(owner, repo, ref, path)` — returns immediate entries only (name, path, type, size), no recursion, no SHA, no content. -- **Whole tree:** `get_repository_tree(owner, repo, tree_sha, recursive)` — `tree_sha` accepts a SHA, branch, or tag name despite the name. Set `recursive: true` to walk subdirectories in one call. Response includes `truncated: true` when a page doesn't hold every entry — page through with `page`/`per_page` (default `page: 1`, `per_page: 30`) until you get fewer results than `per_page`. +`owner`, `repo` and the target branch are caller-supplied. This skill never infers them from a git remote: ask the human when they are not stated, and expect an orchestrating caller to have resolved them already. -## Writing +## Dispatch -- **Creating a new file:** call `create_or_update_file(owner, repo, path, content, message, branch_name)` with `sha` omitted entirely. -- **Updating an existing file:** call `get_file_contents(owner, repo, ref: branch_name, path)` first, take the top-level `sha`, then call `create_or_update_file(..., sha: )`. -- **Deleting a file:** call `get_file_contents` first the same way, then `delete_file(owner, repo, path, message, branch_name, sha: )` — `sha` is required, no create-style fallback exists. -- **Creating a new branch as part of the write:** pass `new_branch_name` on `create_or_update_file` to branch off before the commit lands, instead of calling a separate branch-creation step. +| Condition | Flow | Reference | +|---|---|---| +| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` | +| Create, update, or delete a file | Write | `references/writing.md` | -If the change needs review before merging, or targets a protected branch, hand off to `gitea-prs` after the write lands here to open the pull request — this skill's scope ends at the commit. +If the request only inspects repository contents, read `references/reading.md` — it carries the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs. -If you need the full multi-call sequence rather than the single-call summary above — e.g. branching off as part of a file push ahead of opening a PR, or recovering a SHA you didn't capture earlier — read `references/examples.md`. +If the request creates, updates or deletes a file, read `references/writing.md` — it carries the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails. + +A request that reads and then writes runs both flows in that order: fetch the file first, then write with the SHA that call returned. + +## Handoff + +Scope ends at the commit. Gitea's own web UI edits files directly against a branch, so committing straight to a branch is the normal path rather than an escape hatch — hand off to `gitea-prs` when the change needs review before merging or the target branch is protected, not by default. diff --git a/plugins/gitea/.apm/skills/gitea-files/references/examples.md b/plugins/gitea/.apm/skills/gitea-files/references/examples.md deleted file mode 100644 index c67af6a..0000000 --- a/plugins/gitea/.apm/skills/gitea-files/references/examples.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -source_keys: - - gitea-mcp-repo - - gitea-mcp-slim-go ---- - -# Canonical call sequences - -## Push a file to a new branch, then open a PR - -``` -1. get_repository_tree or get_file_contents on the base branch — only needed if the - new file is actually replacing an existing one; skip for a brand-new path. - -2. create_or_update_file - owner, repo - path: "docs/example.md" - content: "" - message: "docs: add example" - branch_name: "main" - new_branch_name: "feat/add-example" ← branches off before the commit lands - (sha omitted — this is a new file) - -3. Hand off to gitea-prs to open a PR from "feat/add-example" into "main". -``` - -`new_branch_name` on `create_or_update_file` replaces a separate branch-creation call — the branch is created and the commit lands on it in one step. - -## Update a file when you don't already have its SHA - -SHA is mandatory for updates. If it wasn't captured earlier in the conversation: - -``` -1. get_file_contents - owner, repo - ref: "main" - path: "docs/example.md" - → read the top-level `sha` field (not content.sha) - -2. create_or_update_file - owner, repo - path: "docs/example.md" - content: "" - message: "docs: update example" - branch_name: "main" - sha: "" -``` - -Do not guess or omit the SHA — the write either fails (409 on create-path fallback) or is rejected outright. - -## Delete a file - -Same SHA-first pattern, no fallback path: - -``` -1. get_file_contents owner, repo, ref: "main", path: "docs/old-example.md" - → read the top-level `sha` field - -2. delete_file - owner, repo - path: "docs/old-example.md" - message: "docs: remove old example" - branch_name: "main" - sha: "" -``` diff --git a/plugins/gitea/.apm/skills/gitea-files/references/reading.md b/plugins/gitea/.apm/skills/gitea-files/references/reading.md new file mode 100644 index 0000000..ca1847a --- /dev/null +++ b/plugins/gitea/.apm/skills/gitea-files/references/reading.md @@ -0,0 +1,46 @@ +--- +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go +--- + +# Reading files, directories and trees + +All three read calls select what to read with `ref` — a branch name, tag, or commit SHA. On +`get_repository_tree` the same value goes in `tree_sha` despite the name. + +## Single file + +`get_file_contents(owner, repo, ref, path)`. + +The response carries the file's `sha` at the **top level**, not nested under `content`. That field +is the write-ready SHA, so capture it whenever a write may follow. Content comes back +base64-encoded — decode it. Pass `withLines: true` only when you need numbered lines to quote +specific lines back to the user; omit it for a normal content fetch. + +## One directory level + +`get_dir_contents(owner, repo, ref, path)` returns the immediate entries only — name, path, type, +size. No recursion, no content, no `sha`. + +## Whole repository tree + +`get_repository_tree(owner, repo, tree_sha, recursive)`. Set `recursive: true` to walk +subdirectories in one call. + +The response sets `truncated: true` when one page does not hold every entry. Page through with +`page`/`per_page` (defaults `1` and `30`) until a page returns fewer entries than `per_page`. + +## Neither listing is a SHA source for a write + +`get_dir_contents` entries carry no `sha` at all. `get_repository_tree` entries do carry a blob or +tree hash, but reaching it costs an extra round trip and returns no content. `get_file_contents` is +the canonical path for a write's SHA: one call returns the decoded content and the write-ready +`sha` together. + +## A 404 that is really a 403 + +These reads gate on `write:repository`, not on read access alone, and some Gitea endpoints answer +an under-scoped token with 404 instead of 403 so they do not leak whether the resource exists. A +404 on a path you are confident about is a scope problem until proven otherwise — check the token's +configured scopes before concluding the file or directory does not exist. diff --git a/plugins/gitea/.apm/skills/gitea-files/references/sources.md b/plugins/gitea/.apm/skills/gitea-files/references/sources.md index 5b15165..756778d 100644 --- a/plugins/gitea/.apm/skills/gitea-files/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-files/references/sources.md @@ -8,9 +8,10 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** -- SKILL.md (Gotchas, Reading, Writing — tool parameters and SHA/concurrency behavior, cross-checked live against the deployed MCP tool schemas via ToolSearch) -- references/examples.md (canonical call sequences) +**Contributing files:** (tool parameters and SHA/concurrency behavior cross-checked live against the deployed MCP tool schemas via ToolSearch) +- SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch) +- references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) +- references/writing.md (write-tool parameters, SHA/concurrency behavior, canonical call sequences, failed-write triage) ## gitea-mcp-slim-go @@ -21,8 +22,9 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md **Contributing files:** -- SKILL.md (Gotchas — top-level `sha` field location, `get_dir_contents`/`get_repository_tree` not being usable SHA sources for a file write) -- references/examples.md (SHA-first update/delete sequences) +- SKILL.md (Gotchas — base64 response encoding) +- references/reading.md (top-level `sha` field location, `get_dir_contents`/`get_repository_tree` not being usable SHA sources for a file write) +- references/writing.md (SHA-first update/delete sequences) ## context7-websites-gitea @@ -33,7 +35,7 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md **Contributing files:** -- SKILL.md (Gotchas — "Direct commits to a branch are a first-class action, not a workaround") +- SKILL.md (Handoff — committing straight to a branch is the normal path, not an escape hatch) ## context7-gitea-tea-cli diff --git a/plugins/gitea/.apm/skills/gitea-files/references/writing.md b/plugins/gitea/.apm/skills/gitea-files/references/writing.md new file mode 100644 index 0000000..c8c96fd --- /dev/null +++ b/plugins/gitea/.apm/skills/gitea-files/references/writing.md @@ -0,0 +1,76 @@ +--- +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go +--- + +# Creating, updating and deleting files + +`sha` is the optimistic-concurrency token for every write, and it comes from +`get_file_contents`'s **top-level** `sha` field — never from `content.sha`, a directory listing, or +a tree entry. Do not guess or reuse a stale value: a mismatched SHA is rejected exactly like a +missing one. + +`content` is base64-encoded, and the branch the commit lands on is `branch_name`, not `ref`. + +## Create a new file + +Call `create_or_update_file(owner, repo, path, content, message, branch_name)` with `sha` omitted +entirely. An omitted `sha` always means *create*, so the call returns HTTP 409 if the path already +exists. + +To branch off as part of the same write, pass `new_branch_name`: the branch is created and the +commit lands on it in one call, replacing a separate branch-creation step. + +## Update an existing file + +1. `get_file_contents(owner, repo, ref: , path)` → read the top-level `sha`. +2. `create_or_update_file(owner, repo, path, content, message, branch_name, sha: )`. + +## Delete a file + +Same SHA-first pattern, with no create-style fallback — `delete_file` without `sha` returns +HTTP 422. + +1. `get_file_contents(owner, repo, ref: , path)` → read the top-level `sha`. +2. `delete_file(owner, repo, path, message, branch_name, sha: )`. + +## Worked sequence — new file on a new branch, then a PR + +``` +1. create_or_update_file + owner, repo + path: "docs/example.md" + content: "" + message: "docs: add example" + branch_name: "main" + new_branch_name: "feat/add-example" ← branches off before the commit lands + (sha omitted — this is a new file) + +2. Hand off to gitea-prs to open a PR from "feat/add-example" into "main". +``` + +Step 1 needs no preceding read: a brand-new path has no SHA. Fetch the current file first only +when the write replaces an existing one. + +## Triaging a failed write + +| Symptom | Cause | Action | +|---|---|---| +| HTTP 409 | `sha` omitted on a path that already exists | Fetch the current SHA, retry as an update | +| HTTP 422 | `sha` missing or stale | Re-fetch the SHA immediately before the write | +| 403 or 422 with no SHA explanation | Branch protection requires signed commits | Stop and report | +| HTTP 413 | Reverse-proxy body limit in front of Gitea | Report; retrying cannot fix it | +| HTTP 404 | Wrong path, or a token without `write:repository` | Verify the path, then the token's scopes | + +**Signed commits.** These writes create commits server-side from a bare API token with no 2FA or +PGP context. If the target branch's protection rule requires signed commits, Gitea rejects the +write as a generic 403 or 422 that never names signing, and reads against that same branch keep +succeeding right up until the write. When a write fails without a clean 409 or 404 explanation, +check the branch's protection rule before assuming the SHA is wrong and retrying. + +**Payload size.** base64 inflates `content` roughly 33% over the raw file size, and a 413 is +usually a reverse-proxy body-size limit in front of the Gitea instance rather than a Gitea-side +rejection. No amount of retrying, or changing the SHA, path, or branch, will fix it — the proxy's +config has to be raised, which is outside this skill's control. Surface that distinction instead +of retrying the same call. -- 2.43.0 From d578d6b2f2e0f2342f94c0c6f8f09771a419ebbd Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:10:21 +0000 Subject: [PATCH 06/89] refactor(gitea-prs): retrofit to the ADR-0020 context contract Description 709 -> 161 chars, body 683 -> 353 words, Gotchas 8 entries/56% of body -> 2/24.9%. Clears the description FAIL and both Vale CompositionNote errors. Fixes the three stale claims recorded on issue #99, all re-verified against the deployed gitea-mcp schema during review: - The description no longer advertises 'reviewers' as an update capability. editPullRequestFn never reads reviewers or team_reviewers; only add_reviewers/remove_reviewers do. - milestone is now marked honoured on "update" only, in the Gotcha, the body and the dispatch table's create row. On "create" the server discards it and omits the key from the response, so the drop is indistinguishable from never passing it -- and labels DOES apply on create, so labels landing is no evidence the milestone did. The old text told callers to resolve a milestone before any write, wasting the lookup on create. - The superseded un-draft workaround is gone. "update" with draft:false and no title makes the server strip the prefix itself, including [WIP], case-insensitively -- carried by references/pull-requests.md, corrected in PR #106. The 22-row tool/method table becomes a 5-row dispatch table; all 20 operations it named remain reachable, including update_branch and the reviewer methods. Refs #99 --- plugins/gitea/.apm/skills/gitea-prs/README.md | 4 +- plugins/gitea/.apm/skills/gitea-prs/SKILL.md | 67 +++++++------------ .../skills/gitea-prs/references/sources.md | 2 +- 3 files changed, 26 insertions(+), 47 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-prs/README.md b/plugins/gitea/.apm/skills/gitea-prs/README.md index 7236390..b9a084a 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/README.md +++ b/plugins/gitea/.apm/skills/gitea-prs/README.md @@ -4,7 +4,7 @@ List, read, create, update, merge, and review Gitea pull requests. ## What it does -This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), managing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic, and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. +This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), adding and removing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic — `milestone` applies on an update only, never on create — and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. ## Usage @@ -18,7 +18,7 @@ Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents — Gotchas, composition with `gitea-labels-milestones`, and the dispatch table | +| `SKILL.md` | Skill instructions for agents — Gotchas, the dispatch table, and label/milestone ID resolution via `gitea-labels-milestones` | | `references/pull-requests.md` | Execution detail for `list_pull_requests`, `pull_request_read` (get/get_diff/get_files/get_status), and `pull_request_write` (create/update/close/reopen/update_branch/add_reviewers/remove_reviewers) | | `references/reviews.md` | Execution detail for `pull_request_review_write` (create/submit/delete/dismiss) and the review-related `pull_request_read` methods | | `references/merging.md` | The merge workflow — CI vs. review/branch-protection gates, merge styles, branch cleanup, and the post-merge issue-close check | diff --git a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md index c0c2545..b4834ac 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md @@ -2,14 +2,8 @@ name: gitea-prs description: > - Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — - getting PR status/diff/changed files/CI status, opening a PR, updating title/body/reviewers, - closing/reopening, merging with a chosen strategy, or submitting/dismissing a code review with - inline comments. Composes `gitea-labels-milestones` to resolve label names or milestone titles - to the numeric IDs `pull_request_write` requires, rather than duplicating that resolution logic. - Do not use for issues (`gitea-issues`) or branch/commit operations (`gitea-branches`) — a number - the user mentions may refer to either an issue or a PR since they share one number space, so - confirm which domain applies before dispatching. + Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even + when the user does not say "Gitea". Not issues -> `gitea-issues`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. @@ -20,49 +14,34 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli - version: "0.1.1" + version: "0.1.2" allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write --- ## Gotchas -- **Issues and PRs share one number space.** A number the user mentions (`#42`) might be an issue, not a PR — there is only one counter per repo. If you're not certain, call `pull_request_read method: "get"` and treat a 404 as "this number is an issue, not a PR" (or check `is_pull` on an `issue_read` response first if you already have one). -- **`pull_request_read method: "get"` returns `review_scomments`, not `review_comments`.** Source-level typo in gitea-mcp v1.3.0. Never reference `review_comments` — it will always be undefined. -- **`draft: true` on create prepends `"WIP:"` to the title.** Gitea has no first-class draft field — it implements draft PRs via title prefix. To un-draft, call `update` and pass the title without the `WIP:` prefix. -- **Cross-repo fork PRs require `head` as `"fork-owner:branch-name"`.** A bare branch name causes Gitea to search the base repo for it and return 422. Same-repo PRs use a bare branch name. -- **PR `milestone` is a bare title string, not `{id, title}`.** Unlike issues, you cannot recover a milestone's ID from a PR response. If you need the ID (e.g. to filter or to pass to another write), call into `gitea-labels-milestones` and match by title via `milestone_read method: "list"`. -- **CI status and review/approval state are independent merge gates.** `get_status` only reports CI. Branch-protection rules (required approvals, requested-reviewer coverage, stale-approval handling) are enforced server-side by the merge call itself and will error if unmet — passing CI does not mean the merge will succeed. -- **Reviews move through a state machine, not a single write.** `create` opens a review in `PENDING` state with inline comments attached; `submit` finalizes it with a terminal `state` (`APPROVED`/`REQUEST_CHANGES`/`COMMENT`). A submitted review can be `dismiss`ed afterward, but never deleted — `delete` only removes a review that was never submitted. -- **Merging a PR does not auto-close linked issues.** Unlike GitHub, Gitea has no merge-triggers-close event. It does parse closing keywords (`Fixes #N`, `Closes #N`) in commit messages landing on the default branch, so a non-squash merge that preserves those commit messages may auto-close the issue — but a squash merge rewrites history into one commit, so survival of the keyword depends on the squash commit's message. Always call `issue_read method: "get"` on any referenced issue after merging to check whether it already closed before deciding to close it explicitly. - -## Composing `gitea-labels-milestones` - -Before any `pull_request_write` call that includes a `labels` or `milestone` parameter, resolve names/titles to numeric IDs via `gitea-labels-milestones` — `label_read method: "list_repo_labels"` for label name → ID, `milestone_read method: "list"` for milestone title → ID. Never pass a label name string or milestone title string directly to `pull_request_write`; both parameters take numeric IDs only. This skill does not duplicate that lookup logic — it composes the shared skill. +- **Issues and PRs share one number space.** `#42` may be an issue rather than a PR. When unsure, call `pull_request_read method: "get"` and read a 404 as "that number is an issue" — hand it to `gitea-issues`. +- **`pull_request_write method: "create"` discards most optional parameters in silence.** `milestone`, `assignee`, `assignees`, `reviewers` and `team_reviewers` are accepted, dropped, and left out of the response, so a drop is indistinguishable from never passing them. `labels` *does* apply on `"create"`, so labels landing is no evidence the milestone did. ## Dispatch -| Task | Tool | method | -|---|---|---| -| List PRs | `list_pull_requests` | — | -| Get PR details | `pull_request_read` | `"get"` | -| Get PR diff | `pull_request_read` | `"get_diff"` | -| Get PR changed files | `pull_request_read` | `"get_files"` | -| Get PR CI status | `pull_request_read` | `"get_status"` | -| Get PR reviews | `pull_request_read` | `"get_reviews"` | -| Get one review | `pull_request_read` | `"get_review"` | -| Get review inline comments | `pull_request_read` | `"get_review_comments"` | -| Create a PR | `pull_request_write` | `"create"` | -| Update a PR | `pull_request_write` | `"update"` | -| Close a PR | `pull_request_write` | `"close"` | -| Reopen a PR | `pull_request_write` | `"reopen"` | -| Merge a PR | `pull_request_write` | `"merge"` | -| Update branch from base | `pull_request_write` | `"update_branch"` | -| Add reviewers | `pull_request_write` | `"add_reviewers"` | -| Remove reviewers | `pull_request_write` | `"remove_reviewers"` | -| Create a review | `pull_request_review_write` | `"create"` | -| Submit a review | `pull_request_review_write` | `"submit"` | -| Delete a review | `pull_request_review_write` | `"delete"` | -| Dismiss a review | `pull_request_review_write` | `"dismiss"` | +Resolve `owner` and `repo` from context first, and confirm the number names a PR before writing to it. -For full parameter detail on listing/reading/creating/updating/closing PRs, read `references/pull-requests.md`. For review-specific detail (create/submit/delete/dismiss, inline comment shape), read `references/reviews.md`. For the merge workflow specifically (CI gate, merge styles, branch cleanup, post-merge issue check), read `references/merging.md`. +| Task | Tool | Reference | +|---|---|---| +| List PRs; read a PR's details, diff, changed files or CI status | `list_pull_requests`, `pull_request_read` | `references/pull-requests.md` | +| Create a PR — subject to the silent-drop Gotcha above | `pull_request_write` | `references/pull-requests.md` | +| Update, close, reopen or retarget a PR, sync it with its base, or add/remove reviewers | `pull_request_write` | `references/pull-requests.md` | +| Merge a PR, or judge whether it can merge | `pull_request_write method: "merge"` | `references/merging.md` | +| Read, create, submit, dismiss or delete a code review | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | + +Whatever `"create"` dropped takes a second call once the PR exists — `"update"` for milestone and assignees, `"add_reviewers"` for reviewers. + +Read the reference for the row you land on before making the call. Each carries the parameter signatures, the per-method behaviour and the response-shape quirks the row cannot, and every write method has at least one parameter that behaves differently from its issue-side counterpart. + +## Resolving labels and milestones + +`labels` and `milestone` take numeric IDs, never name or title strings. Before a `pull_request_write` call carrying either, resolve them through `gitea-labels-milestones`: `label_read method: "list_repo_labels"` for a label name, `milestone_read method: "list"` for a milestone title. + +Resolve a milestone only when the call is an `"update"` — on `"create"` the lookup is wasted, per the Gotcha above. Recovering an existing PR's milestone ID needs the same lookup, because `pull_request_read` returns `milestone` as a bare title string and never an ID. diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/sources.md b/plugins/gitea/.apm/skills/gitea-prs/references/sources.md index 90d86a6..d1b10ed 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/sources.md @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 — branch protection rules, PR review/merge gating behavior, and automatic issue/PR cross-reference linking. Backfills the external/best-practice gap left by the original docs.gitea.com fetch timeout. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md, references/merging.md +- **Contributing files:** references/merging.md - **Status:** `extracted` ## context7-gitea-tea-cli -- 2.43.0 From 0079f3508c5810e2f5fa2c0e46c5c64f71d082af Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:21:11 +0000 Subject: [PATCH 07/89] fix(gitea-prs): correct reference drift against gitea-mcp v1.7.0 The reference files were last verified against v1.3.0 -- references/sources.md still said so. PR #106 re-verified the write side only, so three defects had accumulated on the read/review side. All three reproduced against the deployed server before being fixed; get_gitea_mcp_server_version reports v1.7.0. - reviews.md forbade review_comments on the "get" response and directed callers to review_scomments, which does not exist. The upstream slim.go typo was corrected; a live pull_request_read on PR #106 returns "review_comments":1 and no review_scomments key. review_comments is an integer count, not comment objects -- distinct from the get_review_comments method. Also fixed in pull-requests.md's response-shape list. - pull_request_review_write grants seven methods; only four were documented. reply_comment, resolve_thread, unresolve_thread and the comment_id parameter had zero mentions anywhere in the skill. Documented from the schema, in a Comment threads section kept outside the numbered review state machine -- they are not lifecycle states. - review_id was documented as required for get_review_comments. It is optional; omitting it lists every inline comment on the PR. Confirmed behaviourally: get_review without it errors, get_review_comments without it returns []. sources.md now records v1.7.0 as the last-verified version, so the next reader knows what these files were checked against. Refs #99 --- plugins/gitea/.apm/skills/gitea-prs/README.md | 4 +-- plugins/gitea/.apm/skills/gitea-prs/SKILL.md | 2 +- .../gitea-prs/references/pull-requests.md | 6 ++--- .../skills/gitea-prs/references/reviews.md | 27 +++++++++++++------ .../skills/gitea-prs/references/sources.md | 4 +-- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-prs/README.md b/plugins/gitea/.apm/skills/gitea-prs/README.md index b9a084a..4d07ed3 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/README.md +++ b/plugins/gitea/.apm/skills/gitea-prs/README.md @@ -4,7 +4,7 @@ List, read, create, update, merge, and review Gitea pull requests. ## What it does -This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), adding and removing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic — `milestone` applies on an update only, never on create — and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. +This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), adding and removing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it, reply to a review comment, and resolve or unresolve a comment thread). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic — `milestone` applies on an update only, never on create — and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. ## Usage @@ -20,6 +20,6 @@ Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create |------|---------| | `SKILL.md` | Skill instructions for agents — Gotchas, the dispatch table, and label/milestone ID resolution via `gitea-labels-milestones` | | `references/pull-requests.md` | Execution detail for `list_pull_requests`, `pull_request_read` (get/get_diff/get_files/get_status), and `pull_request_write` (create/update/close/reopen/update_branch/add_reviewers/remove_reviewers) | -| `references/reviews.md` | Execution detail for `pull_request_review_write` (create/submit/delete/dismiss) and the review-related `pull_request_read` methods | +| `references/reviews.md` | Execution detail for `pull_request_review_write` (create/submit/delete/dismiss, plus the comment-thread methods reply_comment/resolve_thread/unresolve_thread) and the review-related `pull_request_read` methods | | `references/merging.md` | The merge workflow — CI vs. review/branch-protection gates, merge styles, branch cleanup, and the post-merge issue-close check | | `references/sources.md` | Research sources backing the PR/review guidance | diff --git a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md index b4834ac..b14dd3f 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md @@ -34,7 +34,7 @@ Resolve `owner` and `repo` from context first, and confirm the number names a PR | Create a PR — subject to the silent-drop Gotcha above | `pull_request_write` | `references/pull-requests.md` | | Update, close, reopen or retarget a PR, sync it with its base, or add/remove reviewers | `pull_request_write` | `references/pull-requests.md` | | Merge a PR, or judge whether it can merge | `pull_request_write method: "merge"` | `references/merging.md` | -| Read, create, submit, dismiss or delete a code review | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | +| Read, create, submit, dismiss or delete a code review, or reply to and resolve a review comment thread | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | Whatever `"create"` dropped takes a second call once the PR exists — `"update"` for milestone and assignees, `"add_reviewers"` for reviewers. diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md index 05084d4..7f92032 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md @@ -7,7 +7,7 @@ source_keys: # Pull request read/write execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`, and the `review_scomments` typo covered in `references/reviews.md`). Re-verify via `ToolSearch` before trusting this file if the gitea-mcp version changes. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`). These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. ## `list_pull_requests` @@ -29,14 +29,14 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st - `owner` (string, required) - `repo` (string, required) - `pull_number` (number, required) -- `review_id` (number, optional) — required for `"get_review"` and `"get_review_comments"`; see `references/reviews.md` +- `review_id` (number, optional) — required for `"get_review"`, which errors with `review_id is required` without it. **Optional** for `"get_review_comments"`: omit it to list every inline comment on the PR in one call. See `references/reviews.md` - `binary` (boolean, optional) — include binary diff content for `"get_diff"` - `page` (number, optional, default 1) - `per_page` (number, optional, default 30) `"get"`, `"get_diff"`, `"get_files"`, and `"get_status"` are covered here. `"get_reviews"`, `"get_review"`, and `"get_review_comments"` are covered in `references/reviews.md`. -- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); `review_scomments` (typo, see `references/reviews.md`). +- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments` as an integer count, not comment objects (see `references/reviews.md`). - `"get_diff"` returns raw diff text. - `"get_files"` returns the list of changed file objects. - `"get_status"` returns the combined commit status for the PR's head commit — CI result only, not review/approval state (see `references/merging.md`). diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md b/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md index e21d23d..77003b2 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md @@ -7,7 +7,7 @@ source_keys: # PR review execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schema, not copied from the plugin's research doc verbatim — same sourcing discipline as `references/pull-requests.md`. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schema, not copied from the plugin's research doc verbatim — same sourcing discipline as `references/pull-requests.md`. Last verified against **v1.7.0**, as reported by `get_gitea_mcp_server_version`. ## Review state machine @@ -21,23 +21,34 @@ A review is not a single write. It moves through states: ## `pull_request_review_write` **Parameters:** -- `method` (string, required) — `"create"` | `"submit"` | `"delete"` | `"dismiss"` +- `method` (string, required) — `"create"` | `"submit"` | `"delete"` | `"dismiss"` | `"reply_comment"` | `"resolve_thread"` | `"unresolve_thread"` - `owner` (string, required) - `repo` (string, required) -- `pull_number` (number, required) -- `review_id` (number, required for every method except `"create"`, which returns the ID to use for the follow-up `submit`/`delete`/`dismiss` call) +- `pull_number` (number, required for every method except `"resolve_thread"` and `"unresolve_thread"`, which locate the thread from `comment_id` alone) — the schema's own `required` list is only `method`/`owner`/`repo`, so a missing `pull_number` surfaces as a runtime error, not client-side validation +- `review_id` (number) — required for `"submit"`, `"delete"` and `"dismiss"`; `"create"` returns the ID to use for that follow-up call. Not used by `"reply_comment"`, `"resolve_thread"` or `"unresolve_thread"`, which key off `comment_id` instead +- `comment_id` (number, required for `"reply_comment"`, `"resolve_thread"` and `"unresolve_thread"`) — an individual review comment's ID, obtained from `pull_request_read method: "get_review_comments"`. For the two thread methods this must be the thread's **first** comment, not an arbitrary one in it - `state` (string, optional) — `"APPROVED"` | `"REQUEST_CHANGES"` | `"COMMENT"` | `"PENDING"` — set on `"create"` (typically `"PENDING"`, or a terminal state to create-and-submit in one call if the server supports it) or `"submit"` (terminal state) -- `body` (string, optional) — overall review comment text +- `body` (string, optional) — the overall review comment text on `"create"`/`"submit"`; on `"reply_comment"` it is the reply text and is the payload of the call - `commit_id` (string, optional, for `"create"`) — anchors inline comments to a specific commit SHA (typically the PR's current head SHA from `pull_request_read method: "get"`) - `message` (string, optional, for `"dismiss"`) — dismissal reason - `comments` (array of objects, optional, for `"create"`) — inline comments, each: `{path, body, old_line_num, new_line_num}` — `path` is the file path, `body` is the comment text, `new_line_num` anchors to a line in the new (added) side of the diff, `old_line_num` anchors to a line in the old (removed) side; use whichever side the comment applies to, not both +## Comment threads + +Three further methods act on an individual review comment rather than on a review as a whole. They sit outside the state machine above — a thread can be replied to or resolved whatever state its parent review is in — and none of them takes a `review_id`. + +- **`reply_comment`** — posts `body` as a reply to the comment named by `comment_id`, threading under it rather than starting a new top-level comment. Takes `pull_number`. +- **`resolve_thread`** — marks the thread containing `comment_id` resolved. Pass the thread's **first** comment ID; another ID in the same thread is not equivalent. Does not take `pull_number`. +- **`unresolve_thread`** — reopens a resolved thread, under the same first-comment rule. + +Get the `comment_id` from `pull_request_read method: "get_review_comments"`. Call it with no `review_id` to list every inline comment on the PR, then pick the thread to act on; scoping it to one `review_id` only finds threads opened by that review. + ## Reading reviews (`pull_request_read`) - `method: "get_reviews"` — array of review summaries: `id`, `state`, `body`, `user` (login), `comments_count`, `submitted_at`, `html_url`, `stale` (bool — the PR was pushed to after this review was submitted, meaning it may be outdated), `official` (bool), `dismissed` (bool). -- `method: "get_review"` (requires `review_id`) — single review detail. -- `method: "get_review_comments"` (requires `review_id`) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. +- `method: "get_review"` (requires `review_id` — omitting it fails with `review_id is required`) — single review detail. +- `method: "get_review_comments"` (`review_id` **optional** — omit it to list every inline comment on the PR in one call, rather than one review's) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. -**`review_scomments` typo:** the full PR object returned by `pull_request_read method: "get"` includes a field named `review_scomments` (a count), not `review_comments` — a source-level misspelling in gitea-mcp v1.3.0's `slim.go`. Do not write code or instructions that reference `review_comments` on that response; it will always be `undefined`. This is distinct from the `get_review_comments` method above, which is spelled correctly and returns the actual comment objects. +**`review_comments` on the `"get"` response is a count, not the comments.** The full PR object returned by `pull_request_read method: "get"` carries `review_comments` as an integer — the number of inline review comments. It is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. **Inline-comment field names differ between write and read.** The `comments` array on `pull_request_review_write method: "create"` uses `old_line_num`/`new_line_num`. The `get_review_comments` read response uses different field names for the same concept — `position` (new-side line) and `old_position` (old-side line). Do not assume the same key names apply on both sides of the round trip. diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/sources.md b/plugins/gitea/.apm/skills/gitea-prs/references/sources.md index d1b10ed..bc464f6 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/sources.md @@ -3,7 +3,7 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools, their parameters, and CLI flags. Live tool schemas (`list_pull_requests`, `pull_request_read`, `pull_request_write`, `pull_request_review_write`) were verified directly against the deployed MCP server via `ToolSearch` at authoring time, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. +- **Description:** Official gitea-mcp repository — `operation/*.go` source files documenting the MCP tools, their parameters, and CLI flags. Live tool schemas (`list_pull_requests`, `pull_request_read`, `pull_request_write`, `pull_request_review_write`) are verified directly against the deployed MCP server via `ToolSearch`, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. **Last verified against v1.7.0**, as reported by `get_gitea_mcp_server_version`; the files were originally authored against v1.3.0 and the read/review side had drifted by three defects before that re-verification. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/pull-requests.md, references/reviews.md, references/merging.md - **Status:** `extracted` @@ -11,7 +11,7 @@ ## gitea-mcp-slim-go - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/pull/slim.go -- **Description:** Slim response shape structs from gitea-mcp source — defines exactly which fields the MCP server returns for PRs and reviews, including the `review_scomments` typo and the PR-response milestone-as-title-string quirk. +- **Description:** Slim response shape structs from gitea-mcp source — defines exactly which fields the MCP server returns for PRs and reviews, including the PR-response milestone-as-title-string quirk. The `review_scomments` misspelling this file documented at v1.3.0 was corrected upstream; v1.7.0 returns `review_comments`. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/pull-requests.md, references/reviews.md - **Status:** `extracted` -- 2.43.0 From 78015893d4fdfe7235a3ad8fc517900216d47f4a Mon Sep 17 00:00:00 2001 From: Defame1297 Date: Sun, 30 Aug 2026 12:28:54 +0000 Subject: [PATCH 08/89] refactor(gitea-issues): retrofit to the ADR-0020 context contract Description 827 -> 182 chars, body 902 -> 584 words, Gotchas 7 entries/43% of body -> 4/23.1%. Clears both size FAILs, the dangling-target FAIL and the Vale CompositionNote error. The dangling 'gitea-labels' target is gone with the composition sentence that carried it -- a YAML fold artifact, not a typo: the '>'-folded scalar joined 'gitea-labels-' and 'milestones' across a line break, leaving the name terminal and danglable. Both boundary names now sit whole on one source line each, and both resolve. Cut the second trigger register, the seven-verb capability enumeration, the issue_write implementation detail and a gitea-branches boundary that defended nothing. Two Gotchas deleted as spec restatement carried in references/, one as a paraphrase of the step below it. Closes a capability hole rather than papering over it: gitea-labels-milestones routes label application here and has no issue_write, but no dispatch row existed for add/replace/remove/clear_labels or get_labels. Both rows added. The label ID/name Gotcha is now stated per method -- issue_write takes IDs, list_issues and search_issues filter by name, issue_read 'get' returns names but 'get_labels' returns full objects with IDs. Drops a stale quotation of gitea-labels-milestones' description from references/enrichments.md; that string went with its composition note. Refs #99 --- .../gitea/.apm/skills/gitea-issues/README.md | 20 +++- .../gitea/.apm/skills/gitea-issues/SKILL.md | 92 ++++++------------- .../gitea-issues/references/enrichments.md | 3 +- .../skills/gitea-issues/references/sources.md | 4 +- 4 files changed, 48 insertions(+), 71 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-issues/README.md b/plugins/gitea/.apm/skills/gitea-issues/README.md index 7d83306..adf34d8 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/README.md +++ b/plugins/gitea/.apm/skills/gitea-issues/README.md @@ -18,9 +18,19 @@ the blocked `get_me` scope, and the "Depends on #N" dependency-linking conventio Requires a Gitea MCP server configured with a token holding `write:issue` + `write:repository`. Requires a git remote named `origin` pointing at the Gitea instance, unless an orchestrating caller -(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. Label and milestone management -(creating/editing a label, creating/closing a milestone) is out of scope here — that's -`gitea-labels-milestones`, which this skill composes rather than duplicates. +(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. + +## How it composes + +This skill composes `gitea-labels-milestones` for *all* label inference, label-name-to-ID +resolution, and milestone lookup, rather than duplicating that taxonomy or its resolution logic — +see `references/enrichments.md` for the call protocol. Managing the label and milestone definitions +themselves (create/edit/delete a label, create/close a milestone) is out of scope here and goes to +`gitea-labels-milestones` directly. + +One boundary the description does not spend characters on, because it was never going to win an +issue request: local git branch or commit work belongs to `gitea-branches` (Gitea-side) or +`git-branches` (working copy). ## Usage @@ -28,8 +38,8 @@ Requires a git remote named `origin` pointing at the Gitea instance, unless an o /gitea-issues ``` -Describe your task: list issues, create one, get/comment/close a specific issue number, or search -across repos. See `SKILL.md`'s dispatch table for the full set of recognized invocations. +Describe your task: list issues, create one, get/comment/close/label a specific issue number, or +search across repos. See `SKILL.md`'s dispatch table for the full set of recognized invocations. ## Files diff --git a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md index 8cfc6fe..b168d4a 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md @@ -2,15 +2,9 @@ name: gitea-issues description: > - Use when reading or writing Gitea issues: listing repo issues, getting a single issue's details/ - comments/labels, creating an issue, updating its state, adding or editing comments, applying - labels via issue_write, or searching issues/PRs across repositories. Triggers on "create an - issue", "what issues are open", "get issue #N", "close issue #N", "comment on issue #N", "search - issues for X" — even when the user doesn't say "Gitea" explicitly. Composes gitea-labels- - milestones for all label inference/resolution and milestone lookup — do not use this skill to - manage label or milestone definitions themselves (create/edit/delete a label, create/close a - milestone), that's gitea-labels-milestones directly. Do not use for pull requests (use gitea-prs) - or for local git branch/commit work (use gitea-branches or git-branches). + Use when reading or writing Gitea issues — even when the user does not say "Gitea". + Not pull requests -> `gitea-prs`. + Not label or milestone definitions -> `gitea-labels-milestones`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution @@ -19,7 +13,7 @@ compatibility: Requires Gitea MCP server configured with write:issue and write:r metadata: category: integration - version: "0.1.0" + version: "0.1.3" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -31,75 +25,49 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` has no `type` or `milestones` parameter — despite `api-reference.md` documenting both.** The live MCP schema (re-verified via `ToolSearch` at authoring time — see `references/sources.md`) only accepts `owner`, `repo` (required), `state` (default `"all"`), `labels` (array of label *names*), `since`, `before` (ISO 8601), `page`, `per_page` (default 30). This tool provides no way to filter issues-vs-PRs or by milestone. Since issues and PRs share one number space, `list_issues` results can include PR entries with no client-side filter to exclude them. If you need to know whether a specific number is a PR, call `issue_read method: "get"` and check `is_pull` — that field only appears on the single-item response, never in a list item. This exact drift (a prior skill trusted the research doc's `type` param and broke) is why this skill's reference files were re-verified live rather than copied from `api-reference.md`. -- **`search_issues` does have a working `type` filter** (`"issues"` | `"pulls"`) — unlike `list_issues`. Its `labels` parameter is also shaped differently: a comma-separated string, not an array of names. -- **Labels are numeric IDs on write, name strings on read.** `issue_write`'s `labels` parameter (used by `add_labels`/`replace_labels`) takes IDs. `list_issues`/`issue_read` return names. Never resolve this yourself — compose `gitea-labels-milestones` (see `references/enrichments.md`) to get IDs. -- **Milestone on `issue_read` is `{id, title}`** — an object, not a bare string. This skill only ever needs the `id`. (The bare-title-string case only happens on the PR side, which is `gitea-prs`' problem, not this skill's.) -- **Closing-keyword auto-close behavior is plausible but unconfirmed in our research docs.** Our research docs confirm Gitea does NOT auto-close an issue on a plain PR merge (unlike GitHub) — closing keywords like `Fixes #N`/`Closes #N` in a commit message are not documented one way or the other. After a PR merges (a `gitea-prs` operation), always re-check the issue's state here via `issue_read method: "get"` before deciding whether to close it manually — closing an already-closed issue is a harmless no-op, but don't assume a manual close is always needed. -- **Pagination is manual.** `list_issues` and `search_issues` return one page at a time. Iterate `page: 1, 2, ...` until the returned count is less than `per_page`. -- **HTTP 404 may actually mean 403.** Gitea hides permission errors as not-found. If a call 404s unexpectedly, verify the token holds `write:issue` scope (see `references/issues.md`'s Token scope note) before concluding the issue doesn't exist. +- **`list_issues` returns PRs too.** It has no `type` filter and both share one repo number space. `is_pull` appears only on `issue_read method: "get"`, never on a list item — check it there before treating a number as an issue. +- **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. +- **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. +- **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. ## Step 1 — Resolve owner and repo -Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in): +An orchestrating caller may pass `owner` and `repo` in already; if so, skip this. The `search` row is cross-repository and needs only a query, so it skips this too. Otherwise, before any tool call: ```bash git remote get-url origin ``` -If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." ## Step 2 — Dispatch -| Invocation | Action | -|---|---| -| `/gitea-issues` or `/gitea-issues list` | List issues (optional state filter) | -| `/gitea-issues create` | Create an issue from conversation context — infers labels, checks milestone fit, applies a configured default assignee if set | -| `/gitea-issues ` | Get issue details (flag it as a PR if `is_pull: true`) | -| `/gitea-issues comments` | Get an issue's comments | -| `/gitea-issues close ` | Close an issue | -| `/gitea-issues comment ` | Add a comment from conversation context | -| `/gitea-issues search ` | Cross-repo search via `search_issues` | +One invocation takes one row. Read only the reference(s) that row names — the call signatures were verified against the live MCP schema and differ from the published API docs in ways the body does not restate. -For full parameter detail on `list_issues`/`issue_read`/`issue_write`, read `references/issues.md`. For `search_issues`, read `references/search.md`. For the create-flow enrichments (label inference, milestone assignment, assignee workaround, dependency-linking), read `references/enrichments.md`. +| Invocation | Flow | Read | +|---|---|---| +| `/gitea-issues` or `/gitea-issues list` | List issues, optionally filtered by state | `references/issues.md` | +| `/gitea-issues ` | Get one issue, routing to `gitea-prs` when the number turns out to be a PR | `references/issues.md` | +| `/gitea-issues comments` | Get an issue's comments | `references/issues.md` | +| `/gitea-issues labels` | Get an issue's labels as full objects, IDs included | `references/issues.md` | +| `/gitea-issues close ` | Close an issue by updating its state | `references/issues.md` | +| `/gitea-issues comment ` | Add a comment drawn from conversation context, never one invented to fill the gap — a comment on a live issue is not cheap to undo | `references/issues.md` | +| `/gitea-issues label ` | Apply, replace or remove labels using IDs resolved by `gitea-labels-milestones` | `references/issues.md` | +| `/gitea-issues create` | Create an issue — Step 3 first | `references/enrichments.md`, then `references/issues.md` | +| `/gitea-issues search ` | Cross-repo search, narrowed by owner, state, type or labels | `references/search.md` | -## Step 3 — Execute +## Step 3 — Create -### list (default) +Only the create flow reaches this step; every other row goes straight to its reference. -Call `list_issues owner: repo: state: <"open"|"closed"|"all", default "all">`. Remember: results may include PR entries (see Gotchas) — if the caller needs issues only, this tool cannot filter that server-side; note the limitation rather than silently mislabeling PR entries as issues. - -### create - -1. Extract `title` and `body` from conversation context (the most recent task, bug description, or explicit statement). Fall back to an empty body if nothing is available. -2. Run the enrichment sequence in `references/enrichments.md`: infer labels (composing `gitea-labels-milestones`), check for a clearly-fitting open milestone (composing the same skill), and check for a configured default assignee. -3. Call `issue_write method: "create" owner: repo: title: body: <body> labels: [<resolved IDs, or omit>] milestone: <resolved ID, or omit> assignees: [<default login, or omit>]`. -4. Fire immediately — no confirmation step for the create itself. - -### `<N>` (get) - -Call `issue_read method: "get" owner: <owner> repo: <repo> issue_number: <N>`. If `is_pull: true`, report that this number is actually a PR and suggest `gitea-prs` for full detail. - -### `<N> comments` - -Call `issue_read method: "get_comments" owner: <owner> repo: <repo> issue_number: <N>`. - -### close `<N>` - -Call `issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed"`. No `method: "close"` exists. - -### comment `<N>` - -Extract the comment body from conversation context (same sourcing as create). Call `issue_write method: "add_comment" owner: <owner> repo: <repo> issue_number: <N> body: <body>`. - -### search `<query>` - -Call `search_issues query: <query>`, adding `owner`, `state`, `type`, or `labels` filters if the request narrows scope (e.g. "search open PRs for X" → `type: "pulls" state: "open"`). +1. Take `title` and `body` from conversation context — the most recent task, bug report, or explicit statement. An empty body is an acceptable fallback, an invented one is not. +2. Run the enrichments in `references/enrichments.md`, then create with the resolved IDs per `references/issues.md`. Omitting a parameter always beats guessing its value — a wrong milestone or assignee is harder to notice than a missing one. +3. Fire immediately, with no confirmation step. A create is cheap to undo by closing, so a gate here only costs a round trip. ## Step 4 — Report -For reads: a compact table or numbered list — number, title, state, labels, milestone. +Reads: a compact table or numbered list — number, title, state, labels, milestone. -For writes: confirm what was created/updated with the issue number and URL if returned. +Writes: what was created or updated, with the issue number and the URL when one is returned. -For errors: surface the HTTP code and message; check token scope per the Gotchas if a 404 looks wrong. +Errors: the HTTP code and message as returned, without paraphrasing either. diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md b/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md index 09ae4ac..2977587 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md @@ -19,8 +19,7 @@ plausibly fits). reference files directly by path. A plugin install copies each skill's directory into an isolated cache — any file path that leaves this skill's own directory breaks post-install. Instead, compose `gitea-labels-milestones` as a skill: describe the task to it (its own `SKILL.md` and description -trigger it) and consume the resolved IDs it returns. This mirrors how `gitea-labels-milestones`'s -own description already frames the relationship ("`gitea-issues` and `gitea-prs` both compose it"). +trigger it) and consume the resolved IDs it returns. ## 1. Label inference diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/sources.md b/plugins/gitea/.apm/skills/gitea-issues/references/sources.md index 65b6516..904be31 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/sources.md @@ -5,8 +5,8 @@ signatures in `references/issues.md` and `references/search.md` were re-verified `ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied verbatim from `api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a prior skill was authored from API docs that had drifted from the actual MCP tool schema; the live -check caught exactly this drift on `list_issues` (see SKILL.md Gotchas — the research doc documents -a `type` and a `milestones` parameter that do not exist on the deployed server). +check caught exactly this drift on `list_issues` (see `references/issues.md` — the research doc +documents a `type` and a `milestones` parameter that do not exist on the deployed server). ## gitea-mcp-repo -- 2.43.0 From 37382cb72a0a8b72bdbb5fa62d870880e35dc029 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 12:29:18 +0000 Subject: [PATCH 09/89] refactor(gitea-labels-milestones): retrofit to the ADR-0020 context contract Description 835 -> 214 chars, body 669 -> 426 words, Gotchas 8 entries/63% of body -> 3/20.4%. Clears the description FAIL, all three Vale CompositionNote errors and both Gotchas suggestions. 63% was the worst Gotchas ratio in the corpus. Cut the composition sentence to README -- it changes no routing decision and an agent picks this skill because the user asked about labels, not because two other skills call it. Cut the capability enumeration; 'list, create, edit, delete' decompose 'reading or writing' and add no trigger. Boundary clauses are now one arrow per target. The resolver extracts only the first name per arrow clause, so the previous '-> gitea-issues / gitea-prs' left gitea-prs neither dangling nor checked while validate.sh reported 1 of 1. Now 2 of 2. Makes org-scoped label resolution executable. The org label pool was reachable in principle -- four *_org_label* methods, and a claim to own name-to-ID resolution -- but list_org_labels takes org, and Step 1 derived only owner and repo, so both resolution procedures stalled at the fallback. Fixed once at the identity step rather than per-procedure. get_user_orgs is outside allowed-tools, so the failing call is the discriminator: a failure means the owner is a user account with no org pool, which is an answer, not an error. Corrects a Gotcha that was false for create_repo_label/create_org_label and collided with the literal tool name label_write. Same false claim removed from README. Refs #99 --- .../skills/gitea-labels-milestones/README.md | 8 ++++- .../skills/gitea-labels-milestones/SKILL.md | 34 +++++++------------ .../references/label-inference.md | 6 +++- .../references/labels.md | 12 +++++-- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md index 30fb502..d509c71 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md @@ -4,7 +4,13 @@ Read and write Gitea labels and milestones, and resolve label/milestone identity ## What it does -This skill handles label and milestone CRUD (`label_read`/`label_write`, `milestone_read`/`milestone_write`) — listing repo or org labels, creating/editing/deleting a label, resolving a label name to the numeric ID required for any write, and listing/creating/updating/closing/deleting a milestone. It also owns label inference: mapping conversation context (bug report, feature request, urgency language) to this repo's `Kind/*`/`Priority/*`/`Status/*` taxonomy. It is a cross-cutting shared skill composed by `gitea-issues` and `gitea-prs`, which call into it for label/milestone resolution before their own `issue_write`/`pull_request_write` calls apply the resolved IDs. +This skill handles label and milestone CRUD (`label_read`/`label_write`, `milestone_read`/`milestone_write`) — listing repo or org labels, creating/editing/deleting a label, resolving a label name to the numeric ID required to apply it to an issue or PR, and listing/creating/updating/closing/deleting a milestone. It also owns label inference: mapping conversation context (bug report, feature request, urgency language) to this repo's `Kind/*`/`Priority/*`/`Status/*` taxonomy. + +## Composition + +This is a cross-cutting shared skill. `gitea-issues` and `gitea-prs` both compose it whenever they need to apply a label or assign a milestone, rather than duplicating label/milestone logic: they call in for name/title → ID resolution, then their own `issue_write`/`pull_request_write` calls apply the resolved IDs. The split is deliberate — identity resolution lives here once, and the write that attaches an ID to a specific issue or PR lives with the skill that owns that object. + +That relationship is documented here rather than in the skill description, which is preloaded into every session and carries routing information only: an agent reaches this skill because the user asked about labels or milestones, not because two other skills call it. ## Usage diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md index b9844dc..67fd8d8 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md @@ -2,15 +2,9 @@ name: gitea-labels-milestones description: > - Use when reading or writing Gitea labels or milestones — listing repo/org labels, creating, - editing, or deleting a label, resolving label names to the numeric IDs required for applying - them to an issue or PR, or listing, creating, updating, closing, or deleting a milestone. This is - a cross-cutting shared skill: `gitea-issues` and `gitea-prs` both compose it whenever they need to - apply labels or assign a milestone, rather than duplicating label/milestone logic. Also use for - label inference — mapping a bug report, feature request, or urgency signal in conversation - context to the repo's `Kind/*`/`Priority/*`/`Status/*` label taxonomy. Do not use for applying - already-resolved label IDs or milestone IDs to a specific issue or PR — that write goes through - `issue_write`/`pull_request_write` in `gitea-issues`/`gitea-prs`, not here. + Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer + labels — even when the user does not say "Gitea". + Not applying them to an issue -> `gitea-issues`. Not to a PR -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. @@ -21,23 +15,18 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli - version: "0.1.1" + version: "0.1.4" allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write --- ## Gotchas -- **Label writes take IDs, reads return names.** `label_read` is the only tool that returns full label objects (`id`, `name`, `color`, `description`). Issue/PR responses slim labels down to name strings. Before any label is applied to an issue or PR (in `gitea-issues`/`gitea-prs`), resolve names → IDs here via `label_read method: "list_repo_labels"` — never pass a name string where an ID is expected. -- **Milestones are referenced by ID everywhere, never by title.** `milestone_write` update/delete take `id`. The one place titles show up as the sole handle is the `pull_request_read` response (see next gotcha). -- **Milestone representation differs between issues and PRs.** `issue_read` returns `milestone: {id, title}` — an object. `pull_request_read` returns `milestone: "title string"` — title only, no ID. You cannot recover a milestone ID from a PR response directly; call `milestone_read method: "list"` and match by title instead. -- **Repo labels and org labels are separate pools, never mixed in one call.** `label_read`/`label_write` take either `owner`+`repo` (repo-scoped methods) or `org` (org-scoped methods) — passing both or neither for a given method is a caller error, not something the schema enforces for you. Repo and org labels can both apply to the same issue, but you list/create/edit them through different method values. -- **`milestone_write` accepts `"update"` and `"edit"` as the same operation.** Both method values map to the identical update call. Prefer `"update"` for consistency with `issue_write`/`pull_request_write`. -- **`exclusive` is documented as an org-labels-only flag — it isn't what enforces exclusivity here.** Gitea's docs scope the settable/server-enforced `exclusive` flag to org labels only, and the live `label_write` schema for `create_repo_label`/`edit_repo_label` doesn't document accepting it at all. This repo's `Kind/*`, `Priority/*`, `Status/*` groups still behave as one-label-per-scope, but that's a manually-enforced convention this skill implements client-side, not a guaranteed server behavior for repo labels: applying a new label within a scope (e.g. `Priority/High`) must replace any existing label in that same scope, not add alongside it, and nothing on the server enforces that for you. Label inference (see `references/label-inference.md`) must respect this — replace, don't stack. -- **Pagination is manual on every list call.** `label_read` and `milestone_read` both default to `per_page: 30`. Iterate `page: 1, 2, ...` until the result count is less than `per_page` — there is no cursor or auto-pagination. -- **Schema requiredness differs between the two tool families.** `milestone_read`/`milestone_write` have `owner` and `repo` as hard-required parameters (the call fails validation without them). `label_read`/`label_write` only hard-require `method` — `owner`/`repo`/`org` are functionally required per method but not schema-enforced, so passing none produces a runtime error from Gitea, not a client-side validation error. +- **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. +- **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. +- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. -## Step 1 — Resolve owner and repo +## Step 1 — Resolve owner, repo and org Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in): @@ -47,10 +36,13 @@ git remote get-url origin If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. + ## Step 2 — Dispatch | Task | Tool | method | |---|---|---| +| Resolve a label name to its ID | `label_read` | `"list_repo_labels"`, then `"list_org_labels"` | | List repo labels | `label_read` | `"list_repo_labels"` | | Get one repo label by ID | `label_read` | `"get_repo_label"` | | List org labels | `label_read` | `"list_org_labels"` | @@ -63,6 +55,6 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r | Update / close a milestone | `milestone_write` | `"update"` | | Delete a milestone | `milestone_write` | `"delete"` | -For full parameter detail and step-by-step call sequences, read `references/labels.md` (label operations) or `references/milestones.md` (milestone operations). For mapping conversation context to a label to apply, read `references/label-inference.md`. +Every list method paginates manually — `per_page` defaults to 30, so iterate `page: 1, 2, ...` until a page returns fewer results than `per_page`. A truncated list silently breaks name → ID resolution. -Applying resolved label IDs or a milestone ID to a specific issue or PR is out of scope here — that's `issue_write`/`pull_request_write` in the composing skill (`gitea-issues`/`gitea-prs`). +If the task is a label operation, read `references/labels.md`; if a milestone operation, read `references/milestones.md`. If the label to apply has to be derived from conversation context rather than named, read `references/label-inference.md`. diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md index ba4665b..0f7ddab 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md @@ -57,7 +57,11 @@ scope applied at once. 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the signals above. 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current - label set with IDs — inference must never guess an ID, only a name, then resolve it. + label set with IDs — inference must never guess an ID, only a name, then resolve it. Because + `exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any + inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with + `org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner + is a user account, not an organisation, so no org pool exists and the name is genuinely absent. 3. Match inferred label names against the resolved list (case-insensitive). If a scope group already has a different label applied on the target and a new one is inferred for that same scope, plan to replace rather than add (see above). diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md index 56b129e..63939f4 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md @@ -62,9 +62,15 @@ label_read method: "get_repo_label" owner: <owner> repo: <repo> id: <id> ## Resolve a name to an ID There is no direct name lookup. List all repo labels (paginating if needed), scan for a -case-insensitive name match, and extract `id`. This is the required first step before any label -application on an issue or PR — the actual `add_labels`/`replace_labels`/`remove_label` call lives -in `gitea-issues`/`gitea-prs` via `issue_write`/`pull_request_write`, which take numeric IDs only. +case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is +not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method +takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the +owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and +the miss is a real miss. + +Resolution is the required first step before any label application on an issue or PR — the actual +`add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via +`issue_write`/`pull_request_write`, which take numeric IDs only. ## Create a label -- 2.43.0 From 23b843a311e2805fd72893b89146b7f4575c5f9d Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 12:34:24 +0000 Subject: [PATCH 10/89] test: drop the gitea-labels pin now the retrofit removed that target tests/test-adr0020-targets.sh pinned the corpus dangling set as exactly {gitea-labels, neuledge-context} and tests/test-skill-size-check.sh probed both individually. The gitea-issues retrofit cut the composition sentence whose '>'-folded scalar produced 'gitea-labels- milestones', so that target no longer exists and both suites went red. EXPECTED_DANGLING is now {neuledge-context} and the gitea-issues probe is removed rather than skipped, per the rule the probe file states about itself: a probe whose fixture has been retrofitted is a pin that needs updating, not an assertion-free pass counted in the totals. The exact-set assertion stays. An empty expected set is still valid and still pins that no new dangling target appeared -- which is what it becomes once research is retrofitted. Both loops carry a shellcheck SC2043 waiver for the same reason: one entry is the expected steady state, not bad quoting. Refs #99 --- tests/test-adr0020-targets.sh | 29 ++++++++++++++++------------- tests/test-skill-size-check.sh | 13 +++++++++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/test-adr0020-targets.sh b/tests/test-adr0020-targets.sh index 5eb9cc7..f4bf7af 100755 --- a/tests/test-adr0020-targets.sh +++ b/tests/test-adr0020-targets.sh @@ -410,26 +410,29 @@ fi # rather than a "contains" — a false-positive fix that suppressed one of them # would otherwise land green. # -# `gitea-labels` is the subtler of the two and is worth keeping: it is not -# written anywhere as `gitea-labels`. gitea-issues' description says "Composes -# `gitea-labels-\n milestones`" in a `>`-folded scalar, and the fold joins the -# lines into "gitea-labels- milestones" — the trailing hyphen is what keeps the -# token terminal and therefore danglable. +# `gitea-labels` WAS the subtler of the two: it was never written anywhere as +# `gitea-labels`. gitea-issues' description said "Composes `gitea-labels-\n +# milestones`" in a `>`-folded scalar, and the fold joined the lines into +# "gitea-labels- milestones" — the trailing hyphen is what kept the token +# terminal and therefore danglable. The issue #99 retrofit cut that composition +# sentence and the dangling target went with it, so the set is down to one. # -# WHEN ISSUE #100 IS FIXED: update EXPECTED_DANGLING to match. Do not delete the -# assertion — an empty expected set is fine and still pins that no NEW dangling -# target appeared. +# WHEN `research` IS RETROFITTED: drop neuledge-context and leave the set empty. +# Do not delete the assertion — an empty expected set is fine and still pins +# that no NEW dangling target appeared. echo "" -echo "--- the two live dangling targets in the corpus are exactly the two ADR-0020 records ---" -EXPECTED_DANGLING="$(printf '%s\n' gitea-labels neuledge-context)" +echo "--- the live dangling targets in the corpus are exactly the ADR-0020 records still open ---" +EXPECTED_DANGLING="$(printf '%s\n' neuledge-context)" if [[ "$LIVE_DANGLING" == "$EXPECTED_DANGLING" ]]; then - pass "the corpus dangling set is exactly {gitea-labels, neuledge-context}" + pass "the corpus dangling set is exactly {neuledge-context}" else fail "the corpus dangling set changed — expected [$(echo "$EXPECTED_DANGLING" | tr '\n' ' ')], got [$(echo "$LIVE_DANGLING" | tr '\n' ' ')]. If a retrofit fixed one, update EXPECTED_DANGLING; if a false-positive fix silently deleted one, that is the regression this asserts." fi +# shellcheck disable=SC2043 # one probe left by design -- the list shrinks as +# each fixture is retrofitted and reaches zero when `research` lands. Keeping the +# loop means removing the last entry is a one-line edit, not a restructure. for probe in \ - "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context" \ - "plugins/gitea/.apm/skills/gitea-issues/SKILL.md:gitea-labels"; do + "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context"; do probe_file="$REPO_ROOT/${probe%%:*}" probe_name="${probe##*:}" if [[ ! -f "$probe_file" ]]; then diff --git a/tests/test-skill-size-check.sh b/tests/test-skill-size-check.sh index 7c8e9bd..6356551 100755 --- a/tests/test-skill-size-check.sh +++ b/tests/test-skill-size-check.sh @@ -546,16 +546,21 @@ echo "--- the live dangling routing targets are caught (issue #100) ---" # in the totals, which is worse than no probe at all because it makes the suite # look one test stronger than it is. It also contradicted # tests/test-adr0020-targets.sh, which pins the live dangling set as EXACTLY -# {gitea-labels, neuledge-context}; that file is the authority on the set, this -# one only checks the two are individually detected. +# {neuledge-context}; that file is the authority on the set, this one only +# checks each member is individually detected. # # Both SKIP branches are gone with it, for the same reason. A probe whose fixture # has been retrofitted is not "still passing" — it is a pin that needs updating, # here and in the exact-set assertion in test-adr0020-targets.sh, and it should # say so out loud rather than quietly agreeing with whatever it finds. +# The gitea-labels probe was dropped when the issue #99 retrofit cut the +# composition sentence whose YAML fold produced that target. Per the rule above +# it is removed, not skipped. +# shellcheck disable=SC2043 # one probe left by design -- the list shrinks as +# each fixture is retrofitted and reaches zero when `research` lands. Keeping the +# loop means removing the last entry is a one-line edit, not a restructure. for probe in \ - "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context" \ - "plugins/gitea/.apm/skills/gitea-issues/SKILL.md:gitea-labels"; do + "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context"; do probe_file="$REPO_ROOT/${probe%%:*}" probe_name="${probe##*:}" if [[ ! -f "$probe_file" ]]; then -- 2.43.0 From b8c36c36f654eef66bb88f2b77cdfd8f53995c21 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 12:35:48 +0000 Subject: [PATCH 11/89] docs(agents): correct the ADR-0020 gate counts to measured state The bullet is always-loaded context, and all three of its numbers had gone stale as the issue #99 retrofit landed. Measured now: 19 of 39 descriptions and 7 of 39 bodies over their FAIL tier, down from 26 and 9; one dangling routing target left (research -> neuledge-context), down from two. Kyberforge.CompositionNote fired 10 errors across four gitea-* skills and now fires nowhere -- those four were the only carriers and all four have been retrofitted. The 'check both gates' advice stays: skill-size-check still does not cover the Vale half, and any new description can reintroduce the rule. Refs #99 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ae855dc..ba1049d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline.** 26 of 39 descriptions and 9 of 39 bodies exceed their FAIL tier, and the `Kyberforge.CompositionNote` Vale rule fires 10 errors across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow`. Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99. `skill-size-check` will not warn you about the Vale half, so check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline.** 19 of 39 descriptions and 7 of 39 bodies exceed their FAIL tier, and one routing target still dangles (`research` → `neuledge-context`). Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin. The `Kyberforge.CompositionNote` Vale rule currently fires nowhere, but `skill-size-check` does not cover the Vale half and the rule can be reintroduced by any new description, so check both: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. -- 2.43.0 From bedbd1d872fd7ad5ec60278d86e254927619452c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 12:42:04 +0000 Subject: [PATCH 12/89] refactor(gitea-workflow): retrofit to the ADR-0020 context contract Description 1012 -> 347 chars, body 582 -> 170 words, Gotchas 3 entries -> 1 at 22.9% of body. Clears the description FAIL and all four Vale CompositionNote errors -- the last carriers in the corpus, so that rule now fires nowhere. Cut the 'human-facing entry point and router' architecture note, the /gitea migration history and the six-skill composition list; all were already in the README or the routing table. Split three mutually exclusive flows into a dispatch table keyed on invocation shape, each branch self-contained in references/: status-checkin.md, number-resolution.md, skill-index.md. Report stays in the body as the gate common to every branch; each branch's own format moved to its file. The old Step 1-4 numbering presented three alternatives as a sequence. The description grew from an intermediate 283 chars on purpose: that draft had dropped flow 3's trigger entirely, leaving the domain-routing index -- a third of the skill -- reachable only through a tail clause whose grammatical subject was the request rather than the skill. Both gates were green over that. Boundary clauses are one arrow per target, so both resolve (#107: the resolver extracts only the first target per clause and reports 1 of 1 on a clause naming two). The local-git exclusion keeps its wording but drops the route to git-workflow, which would not resolve in a gitea-only install. Known residual: the dispatch conditions are stated twice, as a table and as literal conditionals. That is #109 -- body-discipline.md mandates the literal form while the ADR's cited exemplar, apm-workflow, uses a bare table plus one summary line. Fixing it here would settle that contradiction in a skill rather than in the spec, so it rides with #109. Refs #99, #107, #109 --- .../.apm/skills/gitea-workflow/README.md | 7 +- .../gitea/.apm/skills/gitea-workflow/SKILL.md | 66 +++++-------------- .../references/number-resolution.md | 19 ++++++ .../gitea-workflow/references/skill-index.md | 22 +++++++ .../gitea-workflow/references/sources.md | 8 +-- .../references/status-checkin.md | 15 +++++ 6 files changed, 83 insertions(+), 54 deletions(-) create mode 100644 plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md create mode 100644 plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md create mode 100644 plugins/gitea/.apm/skills/gitea-workflow/references/status-checkin.md diff --git a/plugins/gitea/.apm/skills/gitea-workflow/README.md b/plugins/gitea/.apm/skills/gitea-workflow/README.md index 4c81d20..02ebd19 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/README.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/README.md @@ -4,7 +4,7 @@ Human-facing entry point and router for the Gitea integration. ## What it does -This skill is the conversational front door to the Gitea suite — it replaces the old flat `/gitea` skill. On its own it never calls a Gitea MCP tool; it composes the six domain skills (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`). It handles the no-args status check-in (open issues + open PRs), resolves ambiguous issue-or-PR numbers before dispatching (issues and PRs share one number space), and points a user or agent to the right domain skill when it's unclear which one applies. +This skill is the conversational front door to the Gitea suite — it replaces the old flat `/gitea` skill. On its own it never calls a Gitea MCP tool; it composes the six domain skills (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`). It handles the no-args status check-in (open issues + open PRs), which preserves the original flat `/gitea` skill's default behavior; resolves ambiguous issue-or-PR numbers before dispatching (issues and PRs share one number space); and points a user or agent to the right domain skill when it's unclear which one applies. ## Usage @@ -18,5 +18,8 @@ Invoke with no arguments for a status check-in, with a bare number to resolve an | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents — Gotchas, status view, ambiguous-number resolution, and the domain-skill index | +| `SKILL.md` | Skill instructions for agents — Gotchas, the dispatch table keyed on invocation shape, and the common report gate every branch ends in — each branch's own format lives with its reference file | +| `references/status-checkin.md` | Loaded when the skill is invoked with no specific request — the two parallel open-issue/open-PR reads and the two-section report | +| `references/number-resolution.md` | Loaded when the request carries a bare number that says neither "issue" nor "PR" — the `is_pull` resolution call and the hidden-permission-error 404 | +| `references/skill-index.md` | Loaded when the request names a capability but not which skill owns it — the six-skill routing index | | `references/sources.md` | Research sources backing the routing/status guidance | diff --git a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md index 92b7a6e..eeee87b 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md @@ -2,25 +2,17 @@ name: gitea-workflow description: > - Use when a human wants a general or ambiguous Gitea status check or isn't sure which Gitea - domain skill applies — a no-args check-in ("what's going on in the repo", "any updates?"), - a bare-numbered reference that could be an issue or a PR ("what's the status of #42", "what's - happening with #17"), or a request to discover which Gitea capability handles a task. This is - the human-facing entry point and router for the Gitea integration — it replaces the old flat - `/gitea` invocation (now `/gitea-workflow`) and composes the six domain skills - (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, - `gitea-releases`) rather than calling any Gitea MCP tool directly. Do not use this skill when - the domain is already known and unambiguous — invoke the matching domain skill directly instead - (e.g. "create an issue" → `gitea-issues`, "merge PR #10" → `gitea-prs`, "cut a release" → - `gitea-releases`). Do not use for local git operations with no Gitea component (use - `git-workflow`). + Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that + could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which + domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an + unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. metadata: category: integration - version: "0.1.0" + version: "0.1.3" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -29,46 +21,24 @@ metadata: ## Gotchas -- **This skill never calls a Gitea MCP tool itself.** Every read or write goes through one of the six domain skills. If a request needs a raw `mcp__gitea__*` call that no domain skill exposes, that's a gap in a domain skill, not something to patch here. -- **Issues and PRs share one number space** — a bare number like `#42` could be either. Never guess from context clues alone; resolve it with a real call (see Step 2) before dispatching. -- **A 404 on the resolution call doesn't necessarily mean the number doesn't exist.** Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas). If resolution 404s unexpectedly, say so and suggest checking token scope rather than reporting "no such issue or PR." +- **This skill never calls a Gitea MCP tool itself.** Every read and write goes through a domain skill. A needed `mcp__gitea__*` call that no domain skill exposes is a gap in that skill, not something to patch here. -## Step 1 — Default status view (no args) +## Dispatch -When invoked with no specific request, give a status check-in: +The invocation's shape selects exactly one branch. -1. Invoke `gitea-issues` to list open issues (`state: "open"`). -2. Invoke `gitea-prs` to list open PRs (`state: "open"`). -3. Run both in parallel — they're independent reads. -4. Report as two sections, "Open Issues" and "Open Pull Requests", each as a compact list (number, title). This preserves the original flat `/gitea` skill's default behavior. +| Invocation shape | Flow | Reference | +|---|---|---| +| No arguments, no specific request | Repo status check-in | `references/status-checkin.md` | +| A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | +| A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | -## Step 2 — Resolve an ambiguous number +If the invocation carries no specific request, read `references/status-checkin.md`. -When the user references a bare number without saying "issue" or "PR" (e.g. "what's going on with #42"): +If the request references a bare number and never says "issue" or "PR", read `references/number-resolution.md`. -1. Invoke `gitea-issues` to run `issue_read method: "get"` on that number. -2. Check the response's `is_pull` field: - - `true` → it's a PR. Invoke `gitea-prs` for full PR detail (status, diff, reviews as appropriate to the request) and present that instead. - - `false` or absent → it's an issue. Present the issue detail already retrieved. -3. If the resolution call 404s, don't conclude the number doesn't exist — report the 404 and suggest verifying token scope (`write:issue`) per `gitea-issues`' Gotchas, since permission errors are hidden as not-found in Gitea. +If the request names a capability but not which skill owns it, read `references/skill-index.md`. -Never dispatch to `gitea-issues` or `gitea-prs` based on guessing from phrasing alone ("that sounds like a bug" is not evidence) — always resolve first. +## Report -## Step 3 — Route explicit but domain-unclear requests - -For requests that name a capability but not obviously which skill owns it, use this index: - -| Skill | Covers | -|---|---| -| `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | -| `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | -| `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | -| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | -| `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | -| `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | - -If a request clearly names one of these (e.g. "create a milestone" → `gitea-labels-milestones`, "read this file from the repo" → `gitea-files`), invoke that skill directly rather than routing through here. Use this table only when the user or an upstream agent is unsure which skill applies. - -## Step 4 — Report - -Present results in plain language. For the status view, two labeled sections. For a resolved ambiguous number, say which domain it turned out to be before showing detail ("That's a pull request:" / "That's an issue:"). For routing, name the skill and hand off — don't duplicate its output format, let it report. +Every branch ends here. Present results in plain language; the branch's reference file carries its format. diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md b/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md new file mode 100644 index 0000000..280d567 --- /dev/null +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md @@ -0,0 +1,19 @@ +--- +topic: number-resolution +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go + - context7-websites-gitea +--- + +# Resolving a bare issue-or-PR number + +The user has referenced a bare number without saying "issue" or "PR" (e.g. "what's going on with #42"). Resolve it with a real call — never dispatch to `gitea-issues` or `gitea-prs` by guessing from phrasing alone, because "that sounds like a bug" is not evidence and the two domains share one number space. + +1. Invoke `gitea-issues` to run `issue_read method: "get"` on that number. +2. Check the response's `is_pull` field: + - `true` → it's a PR. Invoke `gitea-prs` for full PR detail (status, diff, reviews as appropriate to the request) and present that instead. + - `false` or absent → it's an issue. Present the issue detail already retrieved. +3. If the resolution call 404s, don't conclude the number doesn't exist. Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas), so report the 404 and suggest verifying the token carries `write:issue` rather than reporting "no such issue or PR." + +Then report per `SKILL.md`'s Report section, saying which domain the number turned out to be before showing detail ("That's a pull request:" / "That's an issue:") — otherwise the user cannot tell the resolution happened. diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md b/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md new file mode 100644 index 0000000..e556e75 --- /dev/null +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md @@ -0,0 +1,22 @@ +--- +topic: skill-index +source_keys: + - gitea-mcp-repo +--- + +# Domain-skill index + +The request names a capability but not obviously which skill owns it. Find the owner here, then invoke it: + +| Skill | Covers | +|---|---| +| `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | +| `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | +| `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | +| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | +| `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | +| `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | + +A request that already names its own owner (e.g. "create a milestone" → `gitea-labels-milestones`, "read this file from the repo" → `gitea-files`) never needed this index — invoke that skill directly rather than routing through here. + +Then report per `SKILL.md`'s Report section: name the skill and hand off — don't duplicate its output format, let it report. diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/sources.md b/plugins/gitea/.apm/skills/gitea-workflow/references/sources.md index 852e7af..27d4fe7 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/sources.md @@ -3,9 +3,9 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools. This skill's status view relies on `list_issues`/`list_pull_requests` semantics (via `gitea-issues`/`gitea-prs`), and its ambiguous-number resolution relies on `issue_read`'s `is_pull` field, both verified against this source at authoring time. +- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools. This skill's status view relies on `list_issues`/`list_pull_requests` semantics (via `gitea-issues`/`gitea-prs`), its ambiguous-number resolution relies on `issue_read`'s `is_pull` field, and its domain-skill index groups that tool surface by owning skill — all verified against this source at authoring time. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/status-checkin.md, references/number-resolution.md, references/skill-index.md - **Status:** `extracted` ## gitea-mcp-slim-go @@ -13,7 +13,7 @@ - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/issue/slim.go - **Description:** Slim response shape structs from gitea-mcp source — confirms `is_pull` is present on a single-item `issue_read` response, the field this skill's resolution step depends on to distinguish an issue from a PR sharing the same number. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/number-resolution.md - **Status:** `extracted` ## context7-websites-gitea @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 — confirms issues and pull requests share a single per-repository number sequence, and that Gitea returns 404 for permission failures rather than a distinct 403, both facts this skill's resolution and error-handling steps depend on. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/number-resolution.md - **Status:** `extracted` ## context7-gitea-tea-cli diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/status-checkin.md b/plugins/gitea/.apm/skills/gitea-workflow/references/status-checkin.md new file mode 100644 index 0000000..987ce6f --- /dev/null +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/status-checkin.md @@ -0,0 +1,15 @@ +--- +topic: status-checkin +source_keys: + - gitea-mcp-repo +--- + +# Status check-in (invoked with no request) + +Give a repo status check-in: + +1. Invoke `gitea-issues` to list open issues (`state: "open"`). +2. Invoke `gitea-prs` to list open PRs (`state: "open"`). Run this alongside step 1 — the two are independent reads, so serialising them only adds latency. +3. Report as two sections, "Open Issues" and "Open Pull Requests", each a compact list of number and title. + +Then report per `SKILL.md`'s Report section. -- 2.43.0 From bbc73008a3641bda2691a6c814163e878f7385d6 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 12:42:20 +0000 Subject: [PATCH 13/89] chore(gitea): regenerate the flat content mirror after the ADR-0020 retrofit Generated output, not authored content: scripts/sync-plugin-content.sh --all. Claude Code has no .apm/ awareness, so this compiled mirror must track .apm/ or the check-plugin-content-sync pre-push hook reports drift. Deferred to a single commit at the end of the wave on purpose. sync_dir runs rm -rf before every copy, so running it while seven agents were editing the same plugin would have raced them; agents were told not to sync for that reason. Refs #99 --- plugins/gitea/skills/gitea-branches/README.md | 12 +++ plugins/gitea/skills/gitea-branches/SKILL.md | 30 +++--- .../gitea-branches/references/branches.md | 2 +- .../gitea-branches/references/commits.md | 2 +- plugins/gitea/skills/gitea-files/README.md | 3 +- plugins/gitea/skills/gitea-files/SKILL.md | 50 +++++----- .../skills/gitea-files/references/examples.md | 65 ------------- .../skills/gitea-files/references/reading.md | 46 ++++++++++ .../skills/gitea-files/references/sources.md | 14 +-- .../skills/gitea-files/references/writing.md | 76 +++++++++++++++ plugins/gitea/skills/gitea-issues/README.md | 20 +++- plugins/gitea/skills/gitea-issues/SKILL.md | 92 ++++++------------- .../gitea-issues/references/enrichments.md | 3 +- .../skills/gitea-issues/references/sources.md | 4 +- .../skills/gitea-labels-milestones/README.md | 8 +- .../skills/gitea-labels-milestones/SKILL.md | 34 +++---- .../references/label-inference.md | 6 +- .../references/labels.md | 12 ++- plugins/gitea/skills/gitea-prs/README.md | 6 +- plugins/gitea/skills/gitea-prs/SKILL.md | 67 +++++--------- .../gitea-prs/references/pull-requests.md | 6 +- .../skills/gitea-prs/references/reviews.md | 27 ++++-- .../skills/gitea-prs/references/sources.md | 6 +- plugins/gitea/skills/gitea-releases/SKILL.md | 29 +++--- .../gitea-releases/references/sources.md | 3 +- plugins/gitea/skills/gitea-workflow/README.md | 7 +- plugins/gitea/skills/gitea-workflow/SKILL.md | 66 ++++--------- .../references/number-resolution.md | 19 ++++ .../gitea-workflow/references/skill-index.md | 22 +++++ .../gitea-workflow/references/sources.md | 8 +- .../references/status-checkin.md | 15 +++ 31 files changed, 407 insertions(+), 353 deletions(-) delete mode 100644 plugins/gitea/skills/gitea-files/references/examples.md create mode 100644 plugins/gitea/skills/gitea-files/references/reading.md create mode 100644 plugins/gitea/skills/gitea-files/references/writing.md create mode 100644 plugins/gitea/skills/gitea-workflow/references/number-resolution.md create mode 100644 plugins/gitea/skills/gitea-workflow/references/skill-index.md create mode 100644 plugins/gitea/skills/gitea-workflow/references/status-checkin.md diff --git a/plugins/gitea/skills/gitea-branches/README.md b/plugins/gitea/skills/gitea-branches/README.md index 1be8bcc..3a99f72 100644 --- a/plugins/gitea/skills/gitea-branches/README.md +++ b/plugins/gitea/skills/gitea-branches/README.md @@ -10,6 +10,18 @@ history (list commits, get a single commit's full detail) against a Gitea reposi pagination conventions specific to Gitea's API (e.g. refusing to delete a protected branch without explicit confirmation, and treating unexpected 404s as possible masked 403s). +## Boundaries + +This skill operates on the Gitea server via the MCP tools, never on your local checkout. Branch +and commit-history work against the working copy belongs to `git-branches` and `git-history`. +Branch references that only exist relative to a pull request — a PR's head or base branch, and +cross-repo fork PR heads in particular — belong to `gitea-prs`; `list_branches` cannot see a fork's +head at all. + +The skill triggers on phrasings like "list branches", "create a branch", "delete a branch", +"what commits are on this branch", "show commit <sha>", and "what changed in that commit", even +when the user does not say "Gitea", as long as the repo's remote is a Gitea instance. + ## Before you start Requires a Gitea MCP server configured with a token that has `write:repository` scope. This is diff --git a/plugins/gitea/skills/gitea-branches/SKILL.md b/plugins/gitea/skills/gitea-branches/SKILL.md index 654ebfc..fc112ff 100644 --- a/plugins/gitea/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/skills/gitea-branches/SKILL.md @@ -2,22 +2,16 @@ name: gitea-branches description: > - Use when managing Gitea repository branches — listing, creating, or deleting - branches — or inspecting commit history within a Gitea repo: listing commits - (optionally filtered by branch or file path) or getting full detail for a - single commit by SHA. Triggers on "list branches", "create a branch", - "delete a branch", "what commits are on this branch", "show commit <sha>", - "what changed in that commit" — even if the user doesn't say "Gitea" - explicitly, as long as the repo's remote is a Gitea instance. Do not use for - local git branch/commit operations on your working copy (use git-branches or - git-history) or for PR-side branch references like cross-repo fork PR heads - (use gitea-prs). + Use when listing, creating, or deleting branches in a Gitea repository, or + reading its commit history — even when the user does not say "Gitea". Not a + local working copy's branches -> `git-branches`. Not local history -> + `git-history`. Not a PR's head or base branch -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. metadata: category: integration - version: "0.1.1" + version: "0.1.2" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -28,11 +22,9 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git ## Gotchas -- **Never delete a protected branch (`main`/`master` by name, or `protected: true` from `list_branches`) without explicit confirmation.** `delete_branch` is a direct API call, not a local `git push` — there is no client-side force-push guard protecting it. Name-matching `main`/`master` is a convenient default but not authoritative — a repo can protect a differently-named default branch. When in doubt, call `list_branches` first and check `protected` on the target; treat deletion of any protected branch as a hard refusal unless the user explicitly confirms in the conversation. -- **404 may actually mean 403.** Gitea hides permission errors as not-found to avoid leaking resource existence. If any of these five tools returns 404 unexpectedly, check token scope (see `references/branches.md` / `references/commits.md`) before concluding the branch or commit doesn't exist. -- **Pagination is manual.** `list_branches` and `list_commits` return one page at a time — no auto-pagination in the MCP layer. When you need a complete list, iterate `page: 1, 2, ...` until the returned count is less than `per_page`. -- **Owner/repo always come from the git remote, never from `get_me`.** Resolve them via `git remote get-url origin` (Step 1 below). `get_me`/`list_my_repos` are blocked under the token scopes this skill assumes. -- **`create_branch`'s source is `old_branch`, not "wherever gitea-mcp feels like."** Omitting `old_branch` forks from the repo's server-side default branch — not necessarily the branch you're currently working on locally. If you want to branch from your current checkout, pass `old_branch` explicitly. +- **404 often means 403.** Gitea masks permission errors as not-found; on an unexpected one, check token scope before reporting a branch or commit missing. +- **Nothing auto-paginates.** `list_branches` and `list_commits` return one page; iterate `page` until the returned count is below `per_page`. +- **`delete_branch` has no force-push guard.** Check `protected` from `list_branches` first and require explicit confirmation — a protected branch need not be named `main`. ## Step 1 — Resolve owner and repo @@ -42,7 +34,7 @@ Before any tool call, extract `owner` and `repo` from the git remote: git remote get-url origin ``` -If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." ## Step 2 — Dispatch @@ -54,7 +46,7 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r | `/gitea-branches commits [on <branch>] [touching <path>]` | List commit history | | `/gitea-branches commit <sha>` | Get full detail for one commit | -For branch operations (list/create/delete), read `references/branches.md`. +For branch operations (list/create/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. For commit operations (list/get), read `references/commits.md`. ## Step 3 — Report @@ -63,4 +55,4 @@ For reads: display branches as name + protected flag; display commits as SHA (sh For writes (create/delete): confirm the action taken, the branch name, and (for create) the base it forked from. -For errors: surface the HTTP code and message. If a 404 is unexpected, re-check token scope per the Gotchas above before reporting "not found" to the user. +For errors: surface the HTTP code and message, applying the 404 gotcha above before reporting "not found" to the user. diff --git a/plugins/gitea/skills/gitea-branches/references/branches.md b/plugins/gitea/skills/gitea-branches/references/branches.md index b94754d..ff4e239 100644 --- a/plugins/gitea/skills/gitea-branches/references/branches.md +++ b/plugins/gitea/skills/gitea-branches/references/branches.md @@ -65,7 +65,7 @@ A branch name collision returns `409 Conflict`. delete_branch owner: <owner> repo: <repo> branch: <name> ``` -Before calling this, see the hard-refusal Gotcha in SKILL.md. If the target branch's name isn't +Before calling this, see the `delete_branch` Gotcha in SKILL.md. If the target branch's name isn't obviously a scratch/feature branch, call `list_branches` first and check `protected` on the matching entry — name-matching `main`/`master` alone isn't authoritative, since a repo can protect a differently-named default branch. Confirm explicitly with the user before deleting anything diff --git a/plugins/gitea/skills/gitea-branches/references/commits.md b/plugins/gitea/skills/gitea-branches/references/commits.md index fddeff5..19f8b3c 100644 --- a/plugins/gitea/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/skills/gitea-branches/references/commits.md @@ -42,7 +42,7 @@ Dispatch defaults: **Response:** one object per commit: `sha`, `html_url`, `created`, `message` (when available), `author` (`{name, email, date}`, when available). -Paginate per the manual-pagination Gotcha in SKILL.md if you need more than one page of history. +Paginate per the pagination Gotcha in SKILL.md if you need more than one page of history. ## `get_commit` diff --git a/plugins/gitea/skills/gitea-files/README.md b/plugins/gitea/skills/gitea-files/README.md index 52f560d..333e899 100644 --- a/plugins/gitea/skills/gitea-files/README.md +++ b/plugins/gitea/skills/gitea-files/README.md @@ -19,5 +19,6 @@ Describe the file task: read a file or directory, walk a tree, create/update a f | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/examples.md` | Canonical call sequences: branch + file + PR, recovering a missing SHA before an update, deleting a file | +| `references/reading.md` | Loaded for the read flow: the three read tools, `ref`/`tree_sha` selection, tree pagination, and why a listing is not a SHA source | +| `references/writing.md` | Loaded for the write flow: the SHA-first create/update/delete sequences, `new_branch_name`, the worked branch + file + PR sequence, and failed-write triage | | `references/sources.md` | Research sources backing the SHA/concurrency and direct-commit-vs-PR guidance | diff --git a/plugins/gitea/skills/gitea-files/SKILL.md b/plugins/gitea/skills/gitea-files/SKILL.md index f687f70..18e47cb 100644 --- a/plugins/gitea/skills/gitea-files/SKILL.md +++ b/plugins/gitea/skills/gitea-files/SKILL.md @@ -2,15 +2,9 @@ name: gitea-files description: > - Use when reading or writing individual files or directory trees in a Gitea repository via the - Gitea MCP server: reading a file's contents, listing a directory, walking a full repository - tree, creating a new file, updating an existing file, or deleting a file. Triggers on "read this - file from the repo", "what's in this directory", "show me the repo tree", "create/update a file - in Gitea", "commit this file to the branch", "delete this file from the repo" — even when the - user doesn't say "Gitea" explicitly, as long as the target is a Gitea-hosted repository. Do not - use for local filesystem file operations (use Read/Write/Edit), for branch or commit history - (use gitea-branches), or for opening a pull request around a file change (use gitea-prs after - the file write completes here). + Use when reading or writing files in a Gitea repository rather than on the local filesystem — + read, list, walk the tree, create, update, or delete — even when the user does not say "Gitea". + Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue @@ -28,29 +22,27 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Gotchas -- **A 404 from any read call may actually be a 403 in disguise.** `get_file_contents`, `get_dir_contents`, and `get_repository_tree` all gate on `write:repository` scope, not just read access — some Gitea endpoints return 404 instead of 403 when the token's scope is insufficient, to avoid leaking whether the resource exists. If a read fails with 404 on a path you're confident is correct, check the token's configured scopes before concluding the file or directory doesn't exist. -- **SHA is the concurrency token for every write — and it lives at the top level of `get_file_contents`'s response, not nested under `content`.** `create_or_update_file` without `sha` is always treated as a *create*: if the path already exists, Gitea returns HTTP 409. `delete_file` has no optional path at all — omitting `sha` returns HTTP 422. The safe sequence for any update or delete is always: call `get_file_contents` first, read the top-level `sha` field, then pass that exact value to the write call. Never guess or reuse a stale SHA — a mismatched SHA is rejected the same as a missing one. -- **A write can also fail because the branch requires signed commits — a separate failure mode from a bad SHA.** `create_or_update_file` and `delete_file` create commits server-side via a bare API token call with no 2FA/PGP context. If the target branch's protection rule requires signed commits, Gitea rejects the write outright — surfaced as a generic 403 or 422, not an error naming "signed commit required," and reads against that same branch keep succeeding right up until you try to write. When a write fails without a clean 409 (missing/stale SHA) or 404 (bad path) explanation, check whether the branch's protection rule requires signed commits before assuming the SHA is wrong and retrying. -- **A large `create_or_update_file` payload can hit a reverse-proxy 413 that has nothing to do with Gitea.** `content` is base64-encoded, which inflates the payload ~33% over the raw file size; a 413 is commonly a reverse-proxy body-size limit in front of the Gitea instance, not a Gitea-side rejection. No amount of retrying, or changing the SHA, path, or branch, will fix it — it needs the proxy's config raised, which is outside this skill's or the calling agent's control. Surface that distinction to the user instead of retrying the same call. -- **`get_dir_contents` and `get_repository_tree` are not SHA sources for a specific file's write.** `get_dir_contents` entries carry no `sha` at all. `get_repository_tree` entries do carry a `sha` (a blob/tree hash), but fetching it means an extra round trip with no content — `get_file_contents` is the canonical path since it returns the decoded content and the write-ready `sha` in one call. -- **`owner` and `repo` are always caller-supplied inputs, never resolved here.** This skill doesn't infer them from a git remote. If invoked directly by a human, ask for them if not stated. If invoked by `gitea-workflow` or an orchestrating agent, expect them to already be resolved and passed in. -- **Direct commits to a branch are a first-class action, not a workaround.** Gitea's own web UI defaults to editing files directly against a branch — `create_or_update_file`/`delete_file` used that way is normal, not an API escape hatch to avoid. The SHA-currency requirement above is the actual risk to manage, not the act of committing directly. -- **`ref` (reads) vs. `branch_name` (writes) are different parameters for the same concept.** `get_file_contents`, `get_dir_contents`, and `get_repository_tree` (as `tree_sha`) all accept a branch name, tag, or commit SHA to select what to read. `create_or_update_file` and `delete_file` instead take `branch_name` — the branch the commit lands on. Don't conflate the two when chaining a read into a write. -- **Content is base64.** `create_or_update_file`'s `content` parameter is base64-encoded file content, not raw text — encode before calling. `get_file_contents`'s response content is likewise base64-encoded (decode after reading), unless `withLines: true` is passed for a numbered-line view. +- **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. +- **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. +- **`content` is base64 both ways.** Encode before a write, decode after a read; `withLines: true` returns numbered lines. -## Reading +## Inputs -- **Single file:** `get_file_contents(owner, repo, ref, path)`. Pass `withLines: true` only when you need line numbers for referencing specific lines (e.g. quoting a snippet back to the user); omit it for a normal content fetch. -- **One directory level:** `get_dir_contents(owner, repo, ref, path)` — returns immediate entries only (name, path, type, size), no recursion, no SHA, no content. -- **Whole tree:** `get_repository_tree(owner, repo, tree_sha, recursive)` — `tree_sha` accepts a SHA, branch, or tag name despite the name. Set `recursive: true` to walk subdirectories in one call. Response includes `truncated: true` when a page doesn't hold every entry — page through with `page`/`per_page` (default `page: 1`, `per_page: 30`) until you get fewer results than `per_page`. +`owner`, `repo` and the target branch are caller-supplied. This skill never infers them from a git remote: ask the human when they are not stated, and expect an orchestrating caller to have resolved them already. -## Writing +## Dispatch -- **Creating a new file:** call `create_or_update_file(owner, repo, path, content, message, branch_name)` with `sha` omitted entirely. -- **Updating an existing file:** call `get_file_contents(owner, repo, ref: branch_name, path)` first, take the top-level `sha`, then call `create_or_update_file(..., sha: <that value>)`. -- **Deleting a file:** call `get_file_contents` first the same way, then `delete_file(owner, repo, path, message, branch_name, sha: <that value>)` — `sha` is required, no create-style fallback exists. -- **Creating a new branch as part of the write:** pass `new_branch_name` on `create_or_update_file` to branch off before the commit lands, instead of calling a separate branch-creation step. +| Condition | Flow | Reference | +|---|---|---| +| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` | +| Create, update, or delete a file | Write | `references/writing.md` | -If the change needs review before merging, or targets a protected branch, hand off to `gitea-prs` after the write lands here to open the pull request — this skill's scope ends at the commit. +If the request only inspects repository contents, read `references/reading.md` — it carries the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs. -If you need the full multi-call sequence rather than the single-call summary above — e.g. branching off as part of a file push ahead of opening a PR, or recovering a SHA you didn't capture earlier — read `references/examples.md`. +If the request creates, updates or deletes a file, read `references/writing.md` — it carries the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails. + +A request that reads and then writes runs both flows in that order: fetch the file first, then write with the SHA that call returned. + +## Handoff + +Scope ends at the commit. Gitea's own web UI edits files directly against a branch, so committing straight to a branch is the normal path rather than an escape hatch — hand off to `gitea-prs` when the change needs review before merging or the target branch is protected, not by default. diff --git a/plugins/gitea/skills/gitea-files/references/examples.md b/plugins/gitea/skills/gitea-files/references/examples.md deleted file mode 100644 index c67af6a..0000000 --- a/plugins/gitea/skills/gitea-files/references/examples.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -source_keys: - - gitea-mcp-repo - - gitea-mcp-slim-go ---- - -# Canonical call sequences - -## Push a file to a new branch, then open a PR - -``` -1. get_repository_tree or get_file_contents on the base branch — only needed if the - new file is actually replacing an existing one; skip for a brand-new path. - -2. create_or_update_file - owner, repo - path: "docs/example.md" - content: "<base64-encoded content>" - message: "docs: add example" - branch_name: "main" - new_branch_name: "feat/add-example" ← branches off before the commit lands - (sha omitted — this is a new file) - -3. Hand off to gitea-prs to open a PR from "feat/add-example" into "main". -``` - -`new_branch_name` on `create_or_update_file` replaces a separate branch-creation call — the branch is created and the commit lands on it in one step. - -## Update a file when you don't already have its SHA - -SHA is mandatory for updates. If it wasn't captured earlier in the conversation: - -``` -1. get_file_contents - owner, repo - ref: "main" - path: "docs/example.md" - → read the top-level `sha` field (not content.sha) - -2. create_or_update_file - owner, repo - path: "docs/example.md" - content: "<new base64-encoded content>" - message: "docs: update example" - branch_name: "main" - sha: "<sha from step 1>" -``` - -Do not guess or omit the SHA — the write either fails (409 on create-path fallback) or is rejected outright. - -## Delete a file - -Same SHA-first pattern, no fallback path: - -``` -1. get_file_contents owner, repo, ref: "main", path: "docs/old-example.md" - → read the top-level `sha` field - -2. delete_file - owner, repo - path: "docs/old-example.md" - message: "docs: remove old example" - branch_name: "main" - sha: "<sha from step 1>" -``` diff --git a/plugins/gitea/skills/gitea-files/references/reading.md b/plugins/gitea/skills/gitea-files/references/reading.md new file mode 100644 index 0000000..ca1847a --- /dev/null +++ b/plugins/gitea/skills/gitea-files/references/reading.md @@ -0,0 +1,46 @@ +--- +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go +--- + +# Reading files, directories and trees + +All three read calls select what to read with `ref` — a branch name, tag, or commit SHA. On +`get_repository_tree` the same value goes in `tree_sha` despite the name. + +## Single file + +`get_file_contents(owner, repo, ref, path)`. + +The response carries the file's `sha` at the **top level**, not nested under `content`. That field +is the write-ready SHA, so capture it whenever a write may follow. Content comes back +base64-encoded — decode it. Pass `withLines: true` only when you need numbered lines to quote +specific lines back to the user; omit it for a normal content fetch. + +## One directory level + +`get_dir_contents(owner, repo, ref, path)` returns the immediate entries only — name, path, type, +size. No recursion, no content, no `sha`. + +## Whole repository tree + +`get_repository_tree(owner, repo, tree_sha, recursive)`. Set `recursive: true` to walk +subdirectories in one call. + +The response sets `truncated: true` when one page does not hold every entry. Page through with +`page`/`per_page` (defaults `1` and `30`) until a page returns fewer entries than `per_page`. + +## Neither listing is a SHA source for a write + +`get_dir_contents` entries carry no `sha` at all. `get_repository_tree` entries do carry a blob or +tree hash, but reaching it costs an extra round trip and returns no content. `get_file_contents` is +the canonical path for a write's SHA: one call returns the decoded content and the write-ready +`sha` together. + +## A 404 that is really a 403 + +These reads gate on `write:repository`, not on read access alone, and some Gitea endpoints answer +an under-scoped token with 404 instead of 403 so they do not leak whether the resource exists. A +404 on a path you are confident about is a scope problem until proven otherwise — check the token's +configured scopes before concluding the file or directory does not exist. diff --git a/plugins/gitea/skills/gitea-files/references/sources.md b/plugins/gitea/skills/gitea-files/references/sources.md index 5b15165..756778d 100644 --- a/plugins/gitea/skills/gitea-files/references/sources.md +++ b/plugins/gitea/skills/gitea-files/references/sources.md @@ -8,9 +8,10 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** -- SKILL.md (Gotchas, Reading, Writing — tool parameters and SHA/concurrency behavior, cross-checked live against the deployed MCP tool schemas via ToolSearch) -- references/examples.md (canonical call sequences) +**Contributing files:** (tool parameters and SHA/concurrency behavior cross-checked live against the deployed MCP tool schemas via ToolSearch) +- SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch) +- references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) +- references/writing.md (write-tool parameters, SHA/concurrency behavior, canonical call sequences, failed-write triage) ## gitea-mcp-slim-go @@ -21,8 +22,9 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md **Contributing files:** -- SKILL.md (Gotchas — top-level `sha` field location, `get_dir_contents`/`get_repository_tree` not being usable SHA sources for a file write) -- references/examples.md (SHA-first update/delete sequences) +- SKILL.md (Gotchas — base64 response encoding) +- references/reading.md (top-level `sha` field location, `get_dir_contents`/`get_repository_tree` not being usable SHA sources for a file write) +- references/writing.md (SHA-first update/delete sequences) ## context7-websites-gitea @@ -33,7 +35,7 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md **Contributing files:** -- SKILL.md (Gotchas — "Direct commits to a branch are a first-class action, not a workaround") +- SKILL.md (Handoff — committing straight to a branch is the normal path, not an escape hatch) ## context7-gitea-tea-cli diff --git a/plugins/gitea/skills/gitea-files/references/writing.md b/plugins/gitea/skills/gitea-files/references/writing.md new file mode 100644 index 0000000..c8c96fd --- /dev/null +++ b/plugins/gitea/skills/gitea-files/references/writing.md @@ -0,0 +1,76 @@ +--- +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go +--- + +# Creating, updating and deleting files + +`sha` is the optimistic-concurrency token for every write, and it comes from +`get_file_contents`'s **top-level** `sha` field — never from `content.sha`, a directory listing, or +a tree entry. Do not guess or reuse a stale value: a mismatched SHA is rejected exactly like a +missing one. + +`content` is base64-encoded, and the branch the commit lands on is `branch_name`, not `ref`. + +## Create a new file + +Call `create_or_update_file(owner, repo, path, content, message, branch_name)` with `sha` omitted +entirely. An omitted `sha` always means *create*, so the call returns HTTP 409 if the path already +exists. + +To branch off as part of the same write, pass `new_branch_name`: the branch is created and the +commit lands on it in one call, replacing a separate branch-creation step. + +## Update an existing file + +1. `get_file_contents(owner, repo, ref: <branch>, path)` → read the top-level `sha`. +2. `create_or_update_file(owner, repo, path, content, message, branch_name, sha: <that value>)`. + +## Delete a file + +Same SHA-first pattern, with no create-style fallback — `delete_file` without `sha` returns +HTTP 422. + +1. `get_file_contents(owner, repo, ref: <branch>, path)` → read the top-level `sha`. +2. `delete_file(owner, repo, path, message, branch_name, sha: <that value>)`. + +## Worked sequence — new file on a new branch, then a PR + +``` +1. create_or_update_file + owner, repo + path: "docs/example.md" + content: "<base64-encoded content>" + message: "docs: add example" + branch_name: "main" + new_branch_name: "feat/add-example" ← branches off before the commit lands + (sha omitted — this is a new file) + +2. Hand off to gitea-prs to open a PR from "feat/add-example" into "main". +``` + +Step 1 needs no preceding read: a brand-new path has no SHA. Fetch the current file first only +when the write replaces an existing one. + +## Triaging a failed write + +| Symptom | Cause | Action | +|---|---|---| +| HTTP 409 | `sha` omitted on a path that already exists | Fetch the current SHA, retry as an update | +| HTTP 422 | `sha` missing or stale | Re-fetch the SHA immediately before the write | +| 403 or 422 with no SHA explanation | Branch protection requires signed commits | Stop and report | +| HTTP 413 | Reverse-proxy body limit in front of Gitea | Report; retrying cannot fix it | +| HTTP 404 | Wrong path, or a token without `write:repository` | Verify the path, then the token's scopes | + +**Signed commits.** These writes create commits server-side from a bare API token with no 2FA or +PGP context. If the target branch's protection rule requires signed commits, Gitea rejects the +write as a generic 403 or 422 that never names signing, and reads against that same branch keep +succeeding right up until the write. When a write fails without a clean 409 or 404 explanation, +check the branch's protection rule before assuming the SHA is wrong and retrying. + +**Payload size.** base64 inflates `content` roughly 33% over the raw file size, and a 413 is +usually a reverse-proxy body-size limit in front of the Gitea instance rather than a Gitea-side +rejection. No amount of retrying, or changing the SHA, path, or branch, will fix it — the proxy's +config has to be raised, which is outside this skill's control. Surface that distinction instead +of retrying the same call. diff --git a/plugins/gitea/skills/gitea-issues/README.md b/plugins/gitea/skills/gitea-issues/README.md index 7d83306..adf34d8 100644 --- a/plugins/gitea/skills/gitea-issues/README.md +++ b/plugins/gitea/skills/gitea-issues/README.md @@ -18,9 +18,19 @@ the blocked `get_me` scope, and the "Depends on #N" dependency-linking conventio Requires a Gitea MCP server configured with a token holding `write:issue` + `write:repository`. Requires a git remote named `origin` pointing at the Gitea instance, unless an orchestrating caller -(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. Label and milestone management -(creating/editing a label, creating/closing a milestone) is out of scope here — that's -`gitea-labels-milestones`, which this skill composes rather than duplicates. +(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. + +## How it composes + +This skill composes `gitea-labels-milestones` for *all* label inference, label-name-to-ID +resolution, and milestone lookup, rather than duplicating that taxonomy or its resolution logic — +see `references/enrichments.md` for the call protocol. Managing the label and milestone definitions +themselves (create/edit/delete a label, create/close a milestone) is out of scope here and goes to +`gitea-labels-milestones` directly. + +One boundary the description does not spend characters on, because it was never going to win an +issue request: local git branch or commit work belongs to `gitea-branches` (Gitea-side) or +`git-branches` (working copy). ## Usage @@ -28,8 +38,8 @@ Requires a git remote named `origin` pointing at the Gitea instance, unless an o /gitea-issues ``` -Describe your task: list issues, create one, get/comment/close a specific issue number, or search -across repos. See `SKILL.md`'s dispatch table for the full set of recognized invocations. +Describe your task: list issues, create one, get/comment/close/label a specific issue number, or +search across repos. See `SKILL.md`'s dispatch table for the full set of recognized invocations. ## Files diff --git a/plugins/gitea/skills/gitea-issues/SKILL.md b/plugins/gitea/skills/gitea-issues/SKILL.md index 8cfc6fe..b168d4a 100644 --- a/plugins/gitea/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/skills/gitea-issues/SKILL.md @@ -2,15 +2,9 @@ name: gitea-issues description: > - Use when reading or writing Gitea issues: listing repo issues, getting a single issue's details/ - comments/labels, creating an issue, updating its state, adding or editing comments, applying - labels via issue_write, or searching issues/PRs across repositories. Triggers on "create an - issue", "what issues are open", "get issue #N", "close issue #N", "comment on issue #N", "search - issues for X" — even when the user doesn't say "Gitea" explicitly. Composes gitea-labels- - milestones for all label inference/resolution and milestone lookup — do not use this skill to - manage label or milestone definitions themselves (create/edit/delete a label, create/close a - milestone), that's gitea-labels-milestones directly. Do not use for pull requests (use gitea-prs) - or for local git branch/commit work (use gitea-branches or git-branches). + Use when reading or writing Gitea issues — even when the user does not say "Gitea". + Not pull requests -> `gitea-prs`. + Not label or milestone definitions -> `gitea-labels-milestones`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution @@ -19,7 +13,7 @@ compatibility: Requires Gitea MCP server configured with write:issue and write:r metadata: category: integration - version: "0.1.0" + version: "0.1.3" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -31,75 +25,49 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` has no `type` or `milestones` parameter — despite `api-reference.md` documenting both.** The live MCP schema (re-verified via `ToolSearch` at authoring time — see `references/sources.md`) only accepts `owner`, `repo` (required), `state` (default `"all"`), `labels` (array of label *names*), `since`, `before` (ISO 8601), `page`, `per_page` (default 30). This tool provides no way to filter issues-vs-PRs or by milestone. Since issues and PRs share one number space, `list_issues` results can include PR entries with no client-side filter to exclude them. If you need to know whether a specific number is a PR, call `issue_read method: "get"` and check `is_pull` — that field only appears on the single-item response, never in a list item. This exact drift (a prior skill trusted the research doc's `type` param and broke) is why this skill's reference files were re-verified live rather than copied from `api-reference.md`. -- **`search_issues` does have a working `type` filter** (`"issues"` | `"pulls"`) — unlike `list_issues`. Its `labels` parameter is also shaped differently: a comma-separated string, not an array of names. -- **Labels are numeric IDs on write, name strings on read.** `issue_write`'s `labels` parameter (used by `add_labels`/`replace_labels`) takes IDs. `list_issues`/`issue_read` return names. Never resolve this yourself — compose `gitea-labels-milestones` (see `references/enrichments.md`) to get IDs. -- **Milestone on `issue_read` is `{id, title}`** — an object, not a bare string. This skill only ever needs the `id`. (The bare-title-string case only happens on the PR side, which is `gitea-prs`' problem, not this skill's.) -- **Closing-keyword auto-close behavior is plausible but unconfirmed in our research docs.** Our research docs confirm Gitea does NOT auto-close an issue on a plain PR merge (unlike GitHub) — closing keywords like `Fixes #N`/`Closes #N` in a commit message are not documented one way or the other. After a PR merges (a `gitea-prs` operation), always re-check the issue's state here via `issue_read method: "get"` before deciding whether to close it manually — closing an already-closed issue is a harmless no-op, but don't assume a manual close is always needed. -- **Pagination is manual.** `list_issues` and `search_issues` return one page at a time. Iterate `page: 1, 2, ...` until the returned count is less than `per_page`. -- **HTTP 404 may actually mean 403.** Gitea hides permission errors as not-found. If a call 404s unexpectedly, verify the token holds `write:issue` scope (see `references/issues.md`'s Token scope note) before concluding the issue doesn't exist. +- **`list_issues` returns PRs too.** It has no `type` filter and both share one repo number space. `is_pull` appears only on `issue_read method: "get"`, never on a list item — check it there before treating a number as an issue. +- **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. +- **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. +- **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. ## Step 1 — Resolve owner and repo -Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in): +An orchestrating caller may pass `owner` and `repo` in already; if so, skip this. The `search` row is cross-repository and needs only a query, so it skips this too. Otherwise, before any tool call: ```bash git remote get-url origin ``` -If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." ## Step 2 — Dispatch -| Invocation | Action | -|---|---| -| `/gitea-issues` or `/gitea-issues list` | List issues (optional state filter) | -| `/gitea-issues create` | Create an issue from conversation context — infers labels, checks milestone fit, applies a configured default assignee if set | -| `/gitea-issues <N>` | Get issue details (flag it as a PR if `is_pull: true`) | -| `/gitea-issues <N> comments` | Get an issue's comments | -| `/gitea-issues close <N>` | Close an issue | -| `/gitea-issues comment <N>` | Add a comment from conversation context | -| `/gitea-issues search <query>` | Cross-repo search via `search_issues` | +One invocation takes one row. Read only the reference(s) that row names — the call signatures were verified against the live MCP schema and differ from the published API docs in ways the body does not restate. -For full parameter detail on `list_issues`/`issue_read`/`issue_write`, read `references/issues.md`. For `search_issues`, read `references/search.md`. For the create-flow enrichments (label inference, milestone assignment, assignee workaround, dependency-linking), read `references/enrichments.md`. +| Invocation | Flow | Read | +|---|---|---| +| `/gitea-issues` or `/gitea-issues list` | List issues, optionally filtered by state | `references/issues.md` | +| `/gitea-issues <N>` | Get one issue, routing to `gitea-prs` when the number turns out to be a PR | `references/issues.md` | +| `/gitea-issues <N> comments` | Get an issue's comments | `references/issues.md` | +| `/gitea-issues <N> labels` | Get an issue's labels as full objects, IDs included | `references/issues.md` | +| `/gitea-issues close <N>` | Close an issue by updating its state | `references/issues.md` | +| `/gitea-issues comment <N>` | Add a comment drawn from conversation context, never one invented to fill the gap — a comment on a live issue is not cheap to undo | `references/issues.md` | +| `/gitea-issues label <N>` | Apply, replace or remove labels using IDs resolved by `gitea-labels-milestones` | `references/issues.md` | +| `/gitea-issues create` | Create an issue — Step 3 first | `references/enrichments.md`, then `references/issues.md` | +| `/gitea-issues search <query>` | Cross-repo search, narrowed by owner, state, type or labels | `references/search.md` | -## Step 3 — Execute +## Step 3 — Create -### list (default) +Only the create flow reaches this step; every other row goes straight to its reference. -Call `list_issues owner: <owner> repo: <repo> state: <"open"|"closed"|"all", default "all">`. Remember: results may include PR entries (see Gotchas) — if the caller needs issues only, this tool cannot filter that server-side; note the limitation rather than silently mislabeling PR entries as issues. - -### create - -1. Extract `title` and `body` from conversation context (the most recent task, bug description, or explicit statement). Fall back to an empty body if nothing is available. -2. Run the enrichment sequence in `references/enrichments.md`: infer labels (composing `gitea-labels-milestones`), check for a clearly-fitting open milestone (composing the same skill), and check for a configured default assignee. -3. Call `issue_write method: "create" owner: <owner> repo: <repo> title: <title> body: <body> labels: [<resolved IDs, or omit>] milestone: <resolved ID, or omit> assignees: [<default login, or omit>]`. -4. Fire immediately — no confirmation step for the create itself. - -### `<N>` (get) - -Call `issue_read method: "get" owner: <owner> repo: <repo> issue_number: <N>`. If `is_pull: true`, report that this number is actually a PR and suggest `gitea-prs` for full detail. - -### `<N> comments` - -Call `issue_read method: "get_comments" owner: <owner> repo: <repo> issue_number: <N>`. - -### close `<N>` - -Call `issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed"`. No `method: "close"` exists. - -### comment `<N>` - -Extract the comment body from conversation context (same sourcing as create). Call `issue_write method: "add_comment" owner: <owner> repo: <repo> issue_number: <N> body: <body>`. - -### search `<query>` - -Call `search_issues query: <query>`, adding `owner`, `state`, `type`, or `labels` filters if the request narrows scope (e.g. "search open PRs for X" → `type: "pulls" state: "open"`). +1. Take `title` and `body` from conversation context — the most recent task, bug report, or explicit statement. An empty body is an acceptable fallback, an invented one is not. +2. Run the enrichments in `references/enrichments.md`, then create with the resolved IDs per `references/issues.md`. Omitting a parameter always beats guessing its value — a wrong milestone or assignee is harder to notice than a missing one. +3. Fire immediately, with no confirmation step. A create is cheap to undo by closing, so a gate here only costs a round trip. ## Step 4 — Report -For reads: a compact table or numbered list — number, title, state, labels, milestone. +Reads: a compact table or numbered list — number, title, state, labels, milestone. -For writes: confirm what was created/updated with the issue number and URL if returned. +Writes: what was created or updated, with the issue number and the URL when one is returned. -For errors: surface the HTTP code and message; check token scope per the Gotchas if a 404 looks wrong. +Errors: the HTTP code and message as returned, without paraphrasing either. diff --git a/plugins/gitea/skills/gitea-issues/references/enrichments.md b/plugins/gitea/skills/gitea-issues/references/enrichments.md index 09ae4ac..2977587 100644 --- a/plugins/gitea/skills/gitea-issues/references/enrichments.md +++ b/plugins/gitea/skills/gitea-issues/references/enrichments.md @@ -19,8 +19,7 @@ plausibly fits). reference files directly by path. A plugin install copies each skill's directory into an isolated cache — any file path that leaves this skill's own directory breaks post-install. Instead, compose `gitea-labels-milestones` as a skill: describe the task to it (its own `SKILL.md` and description -trigger it) and consume the resolved IDs it returns. This mirrors how `gitea-labels-milestones`'s -own description already frames the relationship ("`gitea-issues` and `gitea-prs` both compose it"). +trigger it) and consume the resolved IDs it returns. ## 1. Label inference diff --git a/plugins/gitea/skills/gitea-issues/references/sources.md b/plugins/gitea/skills/gitea-issues/references/sources.md index 65b6516..904be31 100644 --- a/plugins/gitea/skills/gitea-issues/references/sources.md +++ b/plugins/gitea/skills/gitea-issues/references/sources.md @@ -5,8 +5,8 @@ signatures in `references/issues.md` and `references/search.md` were re-verified `ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied verbatim from `api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a prior skill was authored from API docs that had drifted from the actual MCP tool schema; the live -check caught exactly this drift on `list_issues` (see SKILL.md Gotchas — the research doc documents -a `type` and a `milestones` parameter that do not exist on the deployed server). +check caught exactly this drift on `list_issues` (see `references/issues.md` — the research doc +documents a `type` and a `milestones` parameter that do not exist on the deployed server). ## gitea-mcp-repo diff --git a/plugins/gitea/skills/gitea-labels-milestones/README.md b/plugins/gitea/skills/gitea-labels-milestones/README.md index 30fb502..d509c71 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/README.md +++ b/plugins/gitea/skills/gitea-labels-milestones/README.md @@ -4,7 +4,13 @@ Read and write Gitea labels and milestones, and resolve label/milestone identity ## What it does -This skill handles label and milestone CRUD (`label_read`/`label_write`, `milestone_read`/`milestone_write`) — listing repo or org labels, creating/editing/deleting a label, resolving a label name to the numeric ID required for any write, and listing/creating/updating/closing/deleting a milestone. It also owns label inference: mapping conversation context (bug report, feature request, urgency language) to this repo's `Kind/*`/`Priority/*`/`Status/*` taxonomy. It is a cross-cutting shared skill composed by `gitea-issues` and `gitea-prs`, which call into it for label/milestone resolution before their own `issue_write`/`pull_request_write` calls apply the resolved IDs. +This skill handles label and milestone CRUD (`label_read`/`label_write`, `milestone_read`/`milestone_write`) — listing repo or org labels, creating/editing/deleting a label, resolving a label name to the numeric ID required to apply it to an issue or PR, and listing/creating/updating/closing/deleting a milestone. It also owns label inference: mapping conversation context (bug report, feature request, urgency language) to this repo's `Kind/*`/`Priority/*`/`Status/*` taxonomy. + +## Composition + +This is a cross-cutting shared skill. `gitea-issues` and `gitea-prs` both compose it whenever they need to apply a label or assign a milestone, rather than duplicating label/milestone logic: they call in for name/title → ID resolution, then their own `issue_write`/`pull_request_write` calls apply the resolved IDs. The split is deliberate — identity resolution lives here once, and the write that attaches an ID to a specific issue or PR lives with the skill that owns that object. + +That relationship is documented here rather than in the skill description, which is preloaded into every session and carries routing information only: an agent reaches this skill because the user asked about labels or milestones, not because two other skills call it. ## Usage diff --git a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md index b9844dc..67fd8d8 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md @@ -2,15 +2,9 @@ name: gitea-labels-milestones description: > - Use when reading or writing Gitea labels or milestones — listing repo/org labels, creating, - editing, or deleting a label, resolving label names to the numeric IDs required for applying - them to an issue or PR, or listing, creating, updating, closing, or deleting a milestone. This is - a cross-cutting shared skill: `gitea-issues` and `gitea-prs` both compose it whenever they need to - apply labels or assign a milestone, rather than duplicating label/milestone logic. Also use for - label inference — mapping a bug report, feature request, or urgency signal in conversation - context to the repo's `Kind/*`/`Priority/*`/`Status/*` label taxonomy. Do not use for applying - already-resolved label IDs or milestone IDs to a specific issue or PR — that write goes through - `issue_write`/`pull_request_write` in `gitea-issues`/`gitea-prs`, not here. + Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer + labels — even when the user does not say "Gitea". + Not applying them to an issue -> `gitea-issues`. Not to a PR -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. @@ -21,23 +15,18 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli - version: "0.1.1" + version: "0.1.4" allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write --- ## Gotchas -- **Label writes take IDs, reads return names.** `label_read` is the only tool that returns full label objects (`id`, `name`, `color`, `description`). Issue/PR responses slim labels down to name strings. Before any label is applied to an issue or PR (in `gitea-issues`/`gitea-prs`), resolve names → IDs here via `label_read method: "list_repo_labels"` — never pass a name string where an ID is expected. -- **Milestones are referenced by ID everywhere, never by title.** `milestone_write` update/delete take `id`. The one place titles show up as the sole handle is the `pull_request_read` response (see next gotcha). -- **Milestone representation differs between issues and PRs.** `issue_read` returns `milestone: {id, title}` — an object. `pull_request_read` returns `milestone: "title string"` — title only, no ID. You cannot recover a milestone ID from a PR response directly; call `milestone_read method: "list"` and match by title instead. -- **Repo labels and org labels are separate pools, never mixed in one call.** `label_read`/`label_write` take either `owner`+`repo` (repo-scoped methods) or `org` (org-scoped methods) — passing both or neither for a given method is a caller error, not something the schema enforces for you. Repo and org labels can both apply to the same issue, but you list/create/edit them through different method values. -- **`milestone_write` accepts `"update"` and `"edit"` as the same operation.** Both method values map to the identical update call. Prefer `"update"` for consistency with `issue_write`/`pull_request_write`. -- **`exclusive` is documented as an org-labels-only flag — it isn't what enforces exclusivity here.** Gitea's docs scope the settable/server-enforced `exclusive` flag to org labels only, and the live `label_write` schema for `create_repo_label`/`edit_repo_label` doesn't document accepting it at all. This repo's `Kind/*`, `Priority/*`, `Status/*` groups still behave as one-label-per-scope, but that's a manually-enforced convention this skill implements client-side, not a guaranteed server behavior for repo labels: applying a new label within a scope (e.g. `Priority/High`) must replace any existing label in that same scope, not add alongside it, and nothing on the server enforces that for you. Label inference (see `references/label-inference.md`) must respect this — replace, don't stack. -- **Pagination is manual on every list call.** `label_read` and `milestone_read` both default to `per_page: 30`. Iterate `page: 1, 2, ...` until the result count is less than `per_page` — there is no cursor or auto-pagination. -- **Schema requiredness differs between the two tool families.** `milestone_read`/`milestone_write` have `owner` and `repo` as hard-required parameters (the call fails validation without them). `label_read`/`label_write` only hard-require `method` — `owner`/`repo`/`org` are functionally required per method but not schema-enforced, so passing none produces a runtime error from Gitea, not a client-side validation error. +- **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. +- **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. +- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. -## Step 1 — Resolve owner and repo +## Step 1 — Resolve owner, repo and org Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in): @@ -47,10 +36,13 @@ git remote get-url origin If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." +The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. + ## Step 2 — Dispatch | Task | Tool | method | |---|---|---| +| Resolve a label name to its ID | `label_read` | `"list_repo_labels"`, then `"list_org_labels"` | | List repo labels | `label_read` | `"list_repo_labels"` | | Get one repo label by ID | `label_read` | `"get_repo_label"` | | List org labels | `label_read` | `"list_org_labels"` | @@ -63,6 +55,6 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r | Update / close a milestone | `milestone_write` | `"update"` | | Delete a milestone | `milestone_write` | `"delete"` | -For full parameter detail and step-by-step call sequences, read `references/labels.md` (label operations) or `references/milestones.md` (milestone operations). For mapping conversation context to a label to apply, read `references/label-inference.md`. +Every list method paginates manually — `per_page` defaults to 30, so iterate `page: 1, 2, ...` until a page returns fewer results than `per_page`. A truncated list silently breaks name → ID resolution. -Applying resolved label IDs or a milestone ID to a specific issue or PR is out of scope here — that's `issue_write`/`pull_request_write` in the composing skill (`gitea-issues`/`gitea-prs`). +If the task is a label operation, read `references/labels.md`; if a milestone operation, read `references/milestones.md`. If the label to apply has to be derived from conversation context rather than named, read `references/label-inference.md`. diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md index ba4665b..0f7ddab 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md @@ -57,7 +57,11 @@ scope applied at once. 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the signals above. 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current - label set with IDs — inference must never guess an ID, only a name, then resolve it. + label set with IDs — inference must never guess an ID, only a name, then resolve it. Because + `exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any + inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with + `org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner + is a user account, not an organisation, so no org pool exists and the name is genuinely absent. 3. Match inferred label names against the resolved list (case-insensitive). If a scope group already has a different label applied on the target and a new one is inferred for that same scope, plan to replace rather than add (see above). diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md index 56b129e..63939f4 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md @@ -62,9 +62,15 @@ label_read method: "get_repo_label" owner: <owner> repo: <repo> id: <id> ## Resolve a name to an ID There is no direct name lookup. List all repo labels (paginating if needed), scan for a -case-insensitive name match, and extract `id`. This is the required first step before any label -application on an issue or PR — the actual `add_labels`/`replace_labels`/`remove_label` call lives -in `gitea-issues`/`gitea-prs` via `issue_write`/`pull_request_write`, which take numeric IDs only. +case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is +not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method +takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the +owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and +the miss is a real miss. + +Resolution is the required first step before any label application on an issue or PR — the actual +`add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via +`issue_write`/`pull_request_write`, which take numeric IDs only. ## Create a label diff --git a/plugins/gitea/skills/gitea-prs/README.md b/plugins/gitea/skills/gitea-prs/README.md index 7236390..4d07ed3 100644 --- a/plugins/gitea/skills/gitea-prs/README.md +++ b/plugins/gitea/skills/gitea-prs/README.md @@ -4,7 +4,7 @@ List, read, create, update, merge, and review Gitea pull requests. ## What it does -This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), managing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic, and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. +This skill handles the pull request lifecycle within the Gitea integration suite — listing and reading PRs (details, diff, changed files, CI status, reviews), creating them (title, body, labels), updating them (title, body, assignees, labels, milestone), adding and removing reviewers, closing/reopening, merging with a chosen strategy and post-merge branch cleanup, and the full code-review flow (create a review with inline comments, submit it, dismiss or delete it, reply to a review comment, and resolve or unresolve a comment thread). It composes `gitea-labels-milestones` for label/milestone ID resolution rather than duplicating that logic — `milestone` applies on an update only, never on create — and defers to `gitea-issues` for anything that turns out to be an issue rather than a PR (they share one number space) and to `gitea-branches`/`gitea-files` for the underlying branch/file operations behind a PR. ## Usage @@ -18,8 +18,8 @@ Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents — Gotchas, composition with `gitea-labels-milestones`, and the dispatch table | +| `SKILL.md` | Skill instructions for agents — Gotchas, the dispatch table, and label/milestone ID resolution via `gitea-labels-milestones` | | `references/pull-requests.md` | Execution detail for `list_pull_requests`, `pull_request_read` (get/get_diff/get_files/get_status), and `pull_request_write` (create/update/close/reopen/update_branch/add_reviewers/remove_reviewers) | -| `references/reviews.md` | Execution detail for `pull_request_review_write` (create/submit/delete/dismiss) and the review-related `pull_request_read` methods | +| `references/reviews.md` | Execution detail for `pull_request_review_write` (create/submit/delete/dismiss, plus the comment-thread methods reply_comment/resolve_thread/unresolve_thread) and the review-related `pull_request_read` methods | | `references/merging.md` | The merge workflow — CI vs. review/branch-protection gates, merge styles, branch cleanup, and the post-merge issue-close check | | `references/sources.md` | Research sources backing the PR/review guidance | diff --git a/plugins/gitea/skills/gitea-prs/SKILL.md b/plugins/gitea/skills/gitea-prs/SKILL.md index c0c2545..b14dd3f 100644 --- a/plugins/gitea/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/skills/gitea-prs/SKILL.md @@ -2,14 +2,8 @@ name: gitea-prs description: > - Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — - getting PR status/diff/changed files/CI status, opening a PR, updating title/body/reviewers, - closing/reopening, merging with a chosen strategy, or submitting/dismissing a code review with - inline comments. Composes `gitea-labels-milestones` to resolve label names or milestone titles - to the numeric IDs `pull_request_write` requires, rather than duplicating that resolution logic. - Do not use for issues (`gitea-issues`) or branch/commit operations (`gitea-branches`) — a number - the user mentions may refer to either an issue or a PR since they share one number space, so - confirm which domain applies before dispatching. + Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even + when the user does not say "Gitea". Not issues -> `gitea-issues`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. @@ -20,49 +14,34 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli - version: "0.1.1" + version: "0.1.2" allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write --- ## Gotchas -- **Issues and PRs share one number space.** A number the user mentions (`#42`) might be an issue, not a PR — there is only one counter per repo. If you're not certain, call `pull_request_read method: "get"` and treat a 404 as "this number is an issue, not a PR" (or check `is_pull` on an `issue_read` response first if you already have one). -- **`pull_request_read method: "get"` returns `review_scomments`, not `review_comments`.** Source-level typo in gitea-mcp v1.3.0. Never reference `review_comments` — it will always be undefined. -- **`draft: true` on create prepends `"WIP:"` to the title.** Gitea has no first-class draft field — it implements draft PRs via title prefix. To un-draft, call `update` and pass the title without the `WIP:` prefix. -- **Cross-repo fork PRs require `head` as `"fork-owner:branch-name"`.** A bare branch name causes Gitea to search the base repo for it and return 422. Same-repo PRs use a bare branch name. -- **PR `milestone` is a bare title string, not `{id, title}`.** Unlike issues, you cannot recover a milestone's ID from a PR response. If you need the ID (e.g. to filter or to pass to another write), call into `gitea-labels-milestones` and match by title via `milestone_read method: "list"`. -- **CI status and review/approval state are independent merge gates.** `get_status` only reports CI. Branch-protection rules (required approvals, requested-reviewer coverage, stale-approval handling) are enforced server-side by the merge call itself and will error if unmet — passing CI does not mean the merge will succeed. -- **Reviews move through a state machine, not a single write.** `create` opens a review in `PENDING` state with inline comments attached; `submit` finalizes it with a terminal `state` (`APPROVED`/`REQUEST_CHANGES`/`COMMENT`). A submitted review can be `dismiss`ed afterward, but never deleted — `delete` only removes a review that was never submitted. -- **Merging a PR does not auto-close linked issues.** Unlike GitHub, Gitea has no merge-triggers-close event. It does parse closing keywords (`Fixes #N`, `Closes #N`) in commit messages landing on the default branch, so a non-squash merge that preserves those commit messages may auto-close the issue — but a squash merge rewrites history into one commit, so survival of the keyword depends on the squash commit's message. Always call `issue_read method: "get"` on any referenced issue after merging to check whether it already closed before deciding to close it explicitly. - -## Composing `gitea-labels-milestones` - -Before any `pull_request_write` call that includes a `labels` or `milestone` parameter, resolve names/titles to numeric IDs via `gitea-labels-milestones` — `label_read method: "list_repo_labels"` for label name → ID, `milestone_read method: "list"` for milestone title → ID. Never pass a label name string or milestone title string directly to `pull_request_write`; both parameters take numeric IDs only. This skill does not duplicate that lookup logic — it composes the shared skill. +- **Issues and PRs share one number space.** `#42` may be an issue rather than a PR. When unsure, call `pull_request_read method: "get"` and read a 404 as "that number is an issue" — hand it to `gitea-issues`. +- **`pull_request_write method: "create"` discards most optional parameters in silence.** `milestone`, `assignee`, `assignees`, `reviewers` and `team_reviewers` are accepted, dropped, and left out of the response, so a drop is indistinguishable from never passing them. `labels` *does* apply on `"create"`, so labels landing is no evidence the milestone did. ## Dispatch -| Task | Tool | method | -|---|---|---| -| List PRs | `list_pull_requests` | — | -| Get PR details | `pull_request_read` | `"get"` | -| Get PR diff | `pull_request_read` | `"get_diff"` | -| Get PR changed files | `pull_request_read` | `"get_files"` | -| Get PR CI status | `pull_request_read` | `"get_status"` | -| Get PR reviews | `pull_request_read` | `"get_reviews"` | -| Get one review | `pull_request_read` | `"get_review"` | -| Get review inline comments | `pull_request_read` | `"get_review_comments"` | -| Create a PR | `pull_request_write` | `"create"` | -| Update a PR | `pull_request_write` | `"update"` | -| Close a PR | `pull_request_write` | `"close"` | -| Reopen a PR | `pull_request_write` | `"reopen"` | -| Merge a PR | `pull_request_write` | `"merge"` | -| Update branch from base | `pull_request_write` | `"update_branch"` | -| Add reviewers | `pull_request_write` | `"add_reviewers"` | -| Remove reviewers | `pull_request_write` | `"remove_reviewers"` | -| Create a review | `pull_request_review_write` | `"create"` | -| Submit a review | `pull_request_review_write` | `"submit"` | -| Delete a review | `pull_request_review_write` | `"delete"` | -| Dismiss a review | `pull_request_review_write` | `"dismiss"` | +Resolve `owner` and `repo` from context first, and confirm the number names a PR before writing to it. -For full parameter detail on listing/reading/creating/updating/closing PRs, read `references/pull-requests.md`. For review-specific detail (create/submit/delete/dismiss, inline comment shape), read `references/reviews.md`. For the merge workflow specifically (CI gate, merge styles, branch cleanup, post-merge issue check), read `references/merging.md`. +| Task | Tool | Reference | +|---|---|---| +| List PRs; read a PR's details, diff, changed files or CI status | `list_pull_requests`, `pull_request_read` | `references/pull-requests.md` | +| Create a PR — subject to the silent-drop Gotcha above | `pull_request_write` | `references/pull-requests.md` | +| Update, close, reopen or retarget a PR, sync it with its base, or add/remove reviewers | `pull_request_write` | `references/pull-requests.md` | +| Merge a PR, or judge whether it can merge | `pull_request_write method: "merge"` | `references/merging.md` | +| Read, create, submit, dismiss or delete a code review, or reply to and resolve a review comment thread | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | + +Whatever `"create"` dropped takes a second call once the PR exists — `"update"` for milestone and assignees, `"add_reviewers"` for reviewers. + +Read the reference for the row you land on before making the call. Each carries the parameter signatures, the per-method behaviour and the response-shape quirks the row cannot, and every write method has at least one parameter that behaves differently from its issue-side counterpart. + +## Resolving labels and milestones + +`labels` and `milestone` take numeric IDs, never name or title strings. Before a `pull_request_write` call carrying either, resolve them through `gitea-labels-milestones`: `label_read method: "list_repo_labels"` for a label name, `milestone_read method: "list"` for a milestone title. + +Resolve a milestone only when the call is an `"update"` — on `"create"` the lookup is wasted, per the Gotcha above. Recovering an existing PR's milestone ID needs the same lookup, because `pull_request_read` returns `milestone` as a bare title string and never an ID. diff --git a/plugins/gitea/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/skills/gitea-prs/references/pull-requests.md index 05084d4..7f92032 100644 --- a/plugins/gitea/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/skills/gitea-prs/references/pull-requests.md @@ -7,7 +7,7 @@ source_keys: # Pull request read/write execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`, and the `review_scomments` typo covered in `references/reviews.md`). Re-verify via `ToolSearch` before trusting this file if the gitea-mcp version changes. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`). These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. ## `list_pull_requests` @@ -29,14 +29,14 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st - `owner` (string, required) - `repo` (string, required) - `pull_number` (number, required) -- `review_id` (number, optional) — required for `"get_review"` and `"get_review_comments"`; see `references/reviews.md` +- `review_id` (number, optional) — required for `"get_review"`, which errors with `review_id is required` without it. **Optional** for `"get_review_comments"`: omit it to list every inline comment on the PR in one call. See `references/reviews.md` - `binary` (boolean, optional) — include binary diff content for `"get_diff"` - `page` (number, optional, default 1) - `per_page` (number, optional, default 30) `"get"`, `"get_diff"`, `"get_files"`, and `"get_status"` are covered here. `"get_reviews"`, `"get_review"`, and `"get_review_comments"` are covered in `references/reviews.md`. -- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); `review_scomments` (typo, see `references/reviews.md`). +- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments` as an integer count, not comment objects (see `references/reviews.md`). - `"get_diff"` returns raw diff text. - `"get_files"` returns the list of changed file objects. - `"get_status"` returns the combined commit status for the PR's head commit — CI result only, not review/approval state (see `references/merging.md`). diff --git a/plugins/gitea/skills/gitea-prs/references/reviews.md b/plugins/gitea/skills/gitea-prs/references/reviews.md index e21d23d..77003b2 100644 --- a/plugins/gitea/skills/gitea-prs/references/reviews.md +++ b/plugins/gitea/skills/gitea-prs/references/reviews.md @@ -7,7 +7,7 @@ source_keys: # PR review execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schema, not copied from the plugin's research doc verbatim — same sourcing discipline as `references/pull-requests.md`. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schema, not copied from the plugin's research doc verbatim — same sourcing discipline as `references/pull-requests.md`. Last verified against **v1.7.0**, as reported by `get_gitea_mcp_server_version`. ## Review state machine @@ -21,23 +21,34 @@ A review is not a single write. It moves through states: ## `pull_request_review_write` **Parameters:** -- `method` (string, required) — `"create"` | `"submit"` | `"delete"` | `"dismiss"` +- `method` (string, required) — `"create"` | `"submit"` | `"delete"` | `"dismiss"` | `"reply_comment"` | `"resolve_thread"` | `"unresolve_thread"` - `owner` (string, required) - `repo` (string, required) -- `pull_number` (number, required) -- `review_id` (number, required for every method except `"create"`, which returns the ID to use for the follow-up `submit`/`delete`/`dismiss` call) +- `pull_number` (number, required for every method except `"resolve_thread"` and `"unresolve_thread"`, which locate the thread from `comment_id` alone) — the schema's own `required` list is only `method`/`owner`/`repo`, so a missing `pull_number` surfaces as a runtime error, not client-side validation +- `review_id` (number) — required for `"submit"`, `"delete"` and `"dismiss"`; `"create"` returns the ID to use for that follow-up call. Not used by `"reply_comment"`, `"resolve_thread"` or `"unresolve_thread"`, which key off `comment_id` instead +- `comment_id` (number, required for `"reply_comment"`, `"resolve_thread"` and `"unresolve_thread"`) — an individual review comment's ID, obtained from `pull_request_read method: "get_review_comments"`. For the two thread methods this must be the thread's **first** comment, not an arbitrary one in it - `state` (string, optional) — `"APPROVED"` | `"REQUEST_CHANGES"` | `"COMMENT"` | `"PENDING"` — set on `"create"` (typically `"PENDING"`, or a terminal state to create-and-submit in one call if the server supports it) or `"submit"` (terminal state) -- `body` (string, optional) — overall review comment text +- `body` (string, optional) — the overall review comment text on `"create"`/`"submit"`; on `"reply_comment"` it is the reply text and is the payload of the call - `commit_id` (string, optional, for `"create"`) — anchors inline comments to a specific commit SHA (typically the PR's current head SHA from `pull_request_read method: "get"`) - `message` (string, optional, for `"dismiss"`) — dismissal reason - `comments` (array of objects, optional, for `"create"`) — inline comments, each: `{path, body, old_line_num, new_line_num}` — `path` is the file path, `body` is the comment text, `new_line_num` anchors to a line in the new (added) side of the diff, `old_line_num` anchors to a line in the old (removed) side; use whichever side the comment applies to, not both +## Comment threads + +Three further methods act on an individual review comment rather than on a review as a whole. They sit outside the state machine above — a thread can be replied to or resolved whatever state its parent review is in — and none of them takes a `review_id`. + +- **`reply_comment`** — posts `body` as a reply to the comment named by `comment_id`, threading under it rather than starting a new top-level comment. Takes `pull_number`. +- **`resolve_thread`** — marks the thread containing `comment_id` resolved. Pass the thread's **first** comment ID; another ID in the same thread is not equivalent. Does not take `pull_number`. +- **`unresolve_thread`** — reopens a resolved thread, under the same first-comment rule. + +Get the `comment_id` from `pull_request_read method: "get_review_comments"`. Call it with no `review_id` to list every inline comment on the PR, then pick the thread to act on; scoping it to one `review_id` only finds threads opened by that review. + ## Reading reviews (`pull_request_read`) - `method: "get_reviews"` — array of review summaries: `id`, `state`, `body`, `user` (login), `comments_count`, `submitted_at`, `html_url`, `stale` (bool — the PR was pushed to after this review was submitted, meaning it may be outdated), `official` (bool), `dismissed` (bool). -- `method: "get_review"` (requires `review_id`) — single review detail. -- `method: "get_review_comments"` (requires `review_id`) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. +- `method: "get_review"` (requires `review_id` — omitting it fails with `review_id is required`) — single review detail. +- `method: "get_review_comments"` (`review_id` **optional** — omit it to list every inline comment on the PR in one call, rather than one review's) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. -**`review_scomments` typo:** the full PR object returned by `pull_request_read method: "get"` includes a field named `review_scomments` (a count), not `review_comments` — a source-level misspelling in gitea-mcp v1.3.0's `slim.go`. Do not write code or instructions that reference `review_comments` on that response; it will always be `undefined`. This is distinct from the `get_review_comments` method above, which is spelled correctly and returns the actual comment objects. +**`review_comments` on the `"get"` response is a count, not the comments.** The full PR object returned by `pull_request_read method: "get"` carries `review_comments` as an integer — the number of inline review comments. It is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. **Inline-comment field names differ between write and read.** The `comments` array on `pull_request_review_write method: "create"` uses `old_line_num`/`new_line_num`. The `get_review_comments` read response uses different field names for the same concept — `position` (new-side line) and `old_position` (old-side line). Do not assume the same key names apply on both sides of the round trip. diff --git a/plugins/gitea/skills/gitea-prs/references/sources.md b/plugins/gitea/skills/gitea-prs/references/sources.md index 90d86a6..bc464f6 100644 --- a/plugins/gitea/skills/gitea-prs/references/sources.md +++ b/plugins/gitea/skills/gitea-prs/references/sources.md @@ -3,7 +3,7 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools, their parameters, and CLI flags. Live tool schemas (`list_pull_requests`, `pull_request_read`, `pull_request_write`, `pull_request_review_write`) were verified directly against the deployed MCP server via `ToolSearch` at authoring time, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. +- **Description:** Official gitea-mcp repository — `operation/*.go` source files documenting the MCP tools, their parameters, and CLI flags. Live tool schemas (`list_pull_requests`, `pull_request_read`, `pull_request_write`, `pull_request_review_write`) are verified directly against the deployed MCP server via `ToolSearch`, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. **Last verified against v1.7.0**, as reported by `get_gitea_mcp_server_version`; the files were originally authored against v1.3.0 and the read/review side had drifted by three defects before that re-verification. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/pull-requests.md, references/reviews.md, references/merging.md - **Status:** `extracted` @@ -11,7 +11,7 @@ ## gitea-mcp-slim-go - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/pull/slim.go -- **Description:** Slim response shape structs from gitea-mcp source — defines exactly which fields the MCP server returns for PRs and reviews, including the `review_scomments` typo and the PR-response milestone-as-title-string quirk. +- **Description:** Slim response shape structs from gitea-mcp source — defines exactly which fields the MCP server returns for PRs and reviews, including the PR-response milestone-as-title-string quirk. The `review_scomments` misspelling this file documented at v1.3.0 was corrected upstream; v1.7.0 returns `review_comments`. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/pull-requests.md, references/reviews.md - **Status:** `extracted` @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 — branch protection rules, PR review/merge gating behavior, and automatic issue/PR cross-reference linking. Backfills the external/best-practice gap left by the original docs.gitea.com fetch timeout. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md, references/merging.md +- **Contributing files:** references/merging.md - **Status:** `extracted` ## context7-gitea-tea-cli diff --git a/plugins/gitea/skills/gitea-releases/SKILL.md b/plugins/gitea/skills/gitea-releases/SKILL.md index 7bcc8e9..a573c4c 100644 --- a/plugins/gitea/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/skills/gitea-releases/SKILL.md @@ -2,12 +2,9 @@ name: gitea-releases description: > - Use when managing Gitea releases and tags for a repository: listing, creating, or deleting - releases (with draft/prerelease flags and release notes), and listing, creating, or deleting the - underlying git tags. Use even if the user doesn't say "release" explicitly — "cut a v1.2.0", - "publish a prerelease", "tag this commit", or "what's the latest release" all apply. Do not use - for git branch or commit history operations (use gitea-branches) or for issue/PR management (use - gitea-issues / gitea-prs). + Use when managing Gitea releases or the git tags underneath them — list, get, + create, or delete either — even when the user does not say "release" or + "Gitea". Not branches or commit history -> `gitea-branches`. metadata: category: gitea @@ -20,11 +17,9 @@ metadata: ## Gotchas -- **`delete_release` takes a numeric `id`, never a tag name.** `delete_tag` is the mirror opposite — it takes the `tag_name` string, never a numeric id. These two tools are asymmetric on purpose; passing a tag name to `delete_release` or a numeric id to `delete_tag` fails. Always resolve the numeric release id via `list_releases` or `get_release` first if you only have a tag name in hand. -- **Deleting a release does not delete its tag.** They are separate destructive operations against separate resources — a release is a wrapper (title, notes, draft/prerelease flags, assets) around a tag, not the tag itself. If the intent is to remove both, call `delete_release` and `delete_tag` separately. -- **`list_releases`/`list_tags` default to `per_page: 20`**, unlike most other gitea-mcp tools which default to 30. The MCP layer does no auto-pagination — to get a complete result set, loop `page` upward until a page returns fewer than `per_page` results. -- **`is_draft`/`is_pre_release` are explicit booleans the caller sets on `create_release` — never inferred from `tag_name`.** Note the input param is `is_draft`, which maps to the `draft` field on the *response* object (see Dispatch table below and `references/call-signatures.md`) — `draft` is never a valid input key. Practitioner convention (per the `tea` CLI) uses `-beta`/`-rc` suffixes for prereleases (e.g. `v2.0.0-beta.1`), but Gitea does not enforce or infer this from the tag string. If the user names a tag that looks like a prerelease, set `is_pre_release: true` explicitly rather than assuming the flag is redundant with the name. -- **Tag names are conventionally semver, `v`-prefixed** (`v1.2.0`, `v2.0.0-beta.1`), but this is a practitioner convention, not a Gitea constraint — don't reject or rewrite a caller-supplied tag name that doesn't follow it. +- **Deleting a release never deletes its tag, and deleting a tag never deletes the release wrapping it.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. +- **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. +- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. ## Dispatch table @@ -40,13 +35,13 @@ metadata: | Create tag | `create_tag` | `owner`, `repo`, `tag_name` | `target`, `message` | | Delete tag | `delete_tag` | `owner`, `repo`, `tag_name` | — | -`target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. See `references/call-signatures.md` for response shapes. +`target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. ## Workflow -- [ ] **Creating a release:** Call `create_release` directly with `tag_name` + `target` + `title` — Gitea is assumed to create the underlying tag automatically if `tag_name` doesn't already exist (this is plausible behavior inferred from the API shape, not directly confirmed in the research docs), so a separate `create_tag` call is only needed when you want to tag a commit without wrapping it in a release yet. Verify the tag exists afterward if this matters to the caller. Set `is_pre_release`/`is_draft` explicitly per the Gotchas above; don't leave them to default inference. -- [ ] **Deleting a release safely:** Resolve the numeric id first — call `list_releases` (paginate if needed, see Gotchas) or `get_release` if the id is already known, find the entry matching the target `tag_name`, then call `delete_release` with that `id`. Never pass `tag_name` to `delete_release`. -- [ ] **Deleting a tag along with its release:** Delete the release first (frees the id lookup), then call `delete_tag` with the `tag_name` separately — confirm both are intended before proceeding, since each is an independent irreversible operation. -- [ ] **Listing every page:** If the caller needs all releases or tags (not just the first page), loop `page: 1, 2, 3...` until a response has fewer than `per_page` entries. +- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, and `title`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. +- [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. +- [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. +- [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. -If exact response field shapes or additional conventions are needed, read `references/call-signatures.md` and `references/conventions.md`. +If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/skills/gitea-releases/references/sources.md b/plugins/gitea/skills/gitea-releases/references/sources.md index 2b67a9f..cffb8b9 100644 --- a/plugins/gitea/skills/gitea-releases/references/sources.md +++ b/plugins/gitea/skills/gitea-releases/references/sources.md @@ -42,7 +42,6 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/workflow-conventions.md (Release and tag conventions section) **Contributing files:** -- SKILL.md (Gotchas — semver tag naming) -- references/conventions.md +- references/conventions.md (semver tag naming, release-notes sourcing) **Status:** `extracted` diff --git a/plugins/gitea/skills/gitea-workflow/README.md b/plugins/gitea/skills/gitea-workflow/README.md index 4c81d20..02ebd19 100644 --- a/plugins/gitea/skills/gitea-workflow/README.md +++ b/plugins/gitea/skills/gitea-workflow/README.md @@ -4,7 +4,7 @@ Human-facing entry point and router for the Gitea integration. ## What it does -This skill is the conversational front door to the Gitea suite — it replaces the old flat `/gitea` skill. On its own it never calls a Gitea MCP tool; it composes the six domain skills (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`). It handles the no-args status check-in (open issues + open PRs), resolves ambiguous issue-or-PR numbers before dispatching (issues and PRs share one number space), and points a user or agent to the right domain skill when it's unclear which one applies. +This skill is the conversational front door to the Gitea suite — it replaces the old flat `/gitea` skill. On its own it never calls a Gitea MCP tool; it composes the six domain skills (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`). It handles the no-args status check-in (open issues + open PRs), which preserves the original flat `/gitea` skill's default behavior; resolves ambiguous issue-or-PR numbers before dispatching (issues and PRs share one number space); and points a user or agent to the right domain skill when it's unclear which one applies. ## Usage @@ -18,5 +18,8 @@ Invoke with no arguments for a status check-in, with a bare number to resolve an | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents — Gotchas, status view, ambiguous-number resolution, and the domain-skill index | +| `SKILL.md` | Skill instructions for agents — Gotchas, the dispatch table keyed on invocation shape, and the common report gate every branch ends in — each branch's own format lives with its reference file | +| `references/status-checkin.md` | Loaded when the skill is invoked with no specific request — the two parallel open-issue/open-PR reads and the two-section report | +| `references/number-resolution.md` | Loaded when the request carries a bare number that says neither "issue" nor "PR" — the `is_pull` resolution call and the hidden-permission-error 404 | +| `references/skill-index.md` | Loaded when the request names a capability but not which skill owns it — the six-skill routing index | | `references/sources.md` | Research sources backing the routing/status guidance | diff --git a/plugins/gitea/skills/gitea-workflow/SKILL.md b/plugins/gitea/skills/gitea-workflow/SKILL.md index 92b7a6e..eeee87b 100644 --- a/plugins/gitea/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/skills/gitea-workflow/SKILL.md @@ -2,25 +2,17 @@ name: gitea-workflow description: > - Use when a human wants a general or ambiguous Gitea status check or isn't sure which Gitea - domain skill applies — a no-args check-in ("what's going on in the repo", "any updates?"), - a bare-numbered reference that could be an issue or a PR ("what's the status of #42", "what's - happening with #17"), or a request to discover which Gitea capability handles a task. This is - the human-facing entry point and router for the Gitea integration — it replaces the old flat - `/gitea` invocation (now `/gitea-workflow`) and composes the six domain skills - (`gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, - `gitea-releases`) rather than calling any Gitea MCP tool directly. Do not use this skill when - the domain is already known and unambiguous — invoke the matching domain skill directly instead - (e.g. "create an issue" → `gitea-issues`, "merge PR #10" → `gitea-prs`, "cut a release" → - `gitea-releases`). Do not use for local git operations with no Gitea component (use - `git-workflow`). + Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that + could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which + domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an + unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. metadata: category: integration - version: "0.1.0" + version: "0.1.3" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go @@ -29,46 +21,24 @@ metadata: ## Gotchas -- **This skill never calls a Gitea MCP tool itself.** Every read or write goes through one of the six domain skills. If a request needs a raw `mcp__gitea__*` call that no domain skill exposes, that's a gap in a domain skill, not something to patch here. -- **Issues and PRs share one number space** — a bare number like `#42` could be either. Never guess from context clues alone; resolve it with a real call (see Step 2) before dispatching. -- **A 404 on the resolution call doesn't necessarily mean the number doesn't exist.** Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas). If resolution 404s unexpectedly, say so and suggest checking token scope rather than reporting "no such issue or PR." +- **This skill never calls a Gitea MCP tool itself.** Every read and write goes through a domain skill. A needed `mcp__gitea__*` call that no domain skill exposes is a gap in that skill, not something to patch here. -## Step 1 — Default status view (no args) +## Dispatch -When invoked with no specific request, give a status check-in: +The invocation's shape selects exactly one branch. -1. Invoke `gitea-issues` to list open issues (`state: "open"`). -2. Invoke `gitea-prs` to list open PRs (`state: "open"`). -3. Run both in parallel — they're independent reads. -4. Report as two sections, "Open Issues" and "Open Pull Requests", each as a compact list (number, title). This preserves the original flat `/gitea` skill's default behavior. +| Invocation shape | Flow | Reference | +|---|---|---| +| No arguments, no specific request | Repo status check-in | `references/status-checkin.md` | +| A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | +| A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | -## Step 2 — Resolve an ambiguous number +If the invocation carries no specific request, read `references/status-checkin.md`. -When the user references a bare number without saying "issue" or "PR" (e.g. "what's going on with #42"): +If the request references a bare number and never says "issue" or "PR", read `references/number-resolution.md`. -1. Invoke `gitea-issues` to run `issue_read method: "get"` on that number. -2. Check the response's `is_pull` field: - - `true` → it's a PR. Invoke `gitea-prs` for full PR detail (status, diff, reviews as appropriate to the request) and present that instead. - - `false` or absent → it's an issue. Present the issue detail already retrieved. -3. If the resolution call 404s, don't conclude the number doesn't exist — report the 404 and suggest verifying token scope (`write:issue`) per `gitea-issues`' Gotchas, since permission errors are hidden as not-found in Gitea. +If the request names a capability but not which skill owns it, read `references/skill-index.md`. -Never dispatch to `gitea-issues` or `gitea-prs` based on guessing from phrasing alone ("that sounds like a bug" is not evidence) — always resolve first. +## Report -## Step 3 — Route explicit but domain-unclear requests - -For requests that name a capability but not obviously which skill owns it, use this index: - -| Skill | Covers | -|---|---| -| `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | -| `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | -| `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | -| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | -| `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | -| `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | - -If a request clearly names one of these (e.g. "create a milestone" → `gitea-labels-milestones`, "read this file from the repo" → `gitea-files`), invoke that skill directly rather than routing through here. Use this table only when the user or an upstream agent is unsure which skill applies. - -## Step 4 — Report - -Present results in plain language. For the status view, two labeled sections. For a resolved ambiguous number, say which domain it turned out to be before showing detail ("That's a pull request:" / "That's an issue:"). For routing, name the skill and hand off — don't duplicate its output format, let it report. +Every branch ends here. Present results in plain language; the branch's reference file carries its format. diff --git a/plugins/gitea/skills/gitea-workflow/references/number-resolution.md b/plugins/gitea/skills/gitea-workflow/references/number-resolution.md new file mode 100644 index 0000000..280d567 --- /dev/null +++ b/plugins/gitea/skills/gitea-workflow/references/number-resolution.md @@ -0,0 +1,19 @@ +--- +topic: number-resolution +source_keys: + - gitea-mcp-repo + - gitea-mcp-slim-go + - context7-websites-gitea +--- + +# Resolving a bare issue-or-PR number + +The user has referenced a bare number without saying "issue" or "PR" (e.g. "what's going on with #42"). Resolve it with a real call — never dispatch to `gitea-issues` or `gitea-prs` by guessing from phrasing alone, because "that sounds like a bug" is not evidence and the two domains share one number space. + +1. Invoke `gitea-issues` to run `issue_read method: "get"` on that number. +2. Check the response's `is_pull` field: + - `true` → it's a PR. Invoke `gitea-prs` for full PR detail (status, diff, reviews as appropriate to the request) and present that instead. + - `false` or absent → it's an issue. Present the issue detail already retrieved. +3. If the resolution call 404s, don't conclude the number doesn't exist. Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas), so report the 404 and suggest verifying the token carries `write:issue` rather than reporting "no such issue or PR." + +Then report per `SKILL.md`'s Report section, saying which domain the number turned out to be before showing detail ("That's a pull request:" / "That's an issue:") — otherwise the user cannot tell the resolution happened. diff --git a/plugins/gitea/skills/gitea-workflow/references/skill-index.md b/plugins/gitea/skills/gitea-workflow/references/skill-index.md new file mode 100644 index 0000000..e556e75 --- /dev/null +++ b/plugins/gitea/skills/gitea-workflow/references/skill-index.md @@ -0,0 +1,22 @@ +--- +topic: skill-index +source_keys: + - gitea-mcp-repo +--- + +# Domain-skill index + +The request names a capability but not obviously which skill owns it. Find the owner here, then invoke it: + +| Skill | Covers | +|---|---| +| `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | +| `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | +| `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | +| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | +| `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | +| `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | + +A request that already names its own owner (e.g. "create a milestone" → `gitea-labels-milestones`, "read this file from the repo" → `gitea-files`) never needed this index — invoke that skill directly rather than routing through here. + +Then report per `SKILL.md`'s Report section: name the skill and hand off — don't duplicate its output format, let it report. diff --git a/plugins/gitea/skills/gitea-workflow/references/sources.md b/plugins/gitea/skills/gitea-workflow/references/sources.md index 852e7af..27d4fe7 100644 --- a/plugins/gitea/skills/gitea-workflow/references/sources.md +++ b/plugins/gitea/skills/gitea-workflow/references/sources.md @@ -3,9 +3,9 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools. This skill's status view relies on `list_issues`/`list_pull_requests` semantics (via `gitea-issues`/`gitea-prs`), and its ambiguous-number resolution relies on `issue_read`'s `is_pull` field, both verified against this source at authoring time. +- **Description:** Official gitea-mcp repository (v1.3.0) — `operation/*.go` source files documenting all 55 MCP tools. This skill's status view relies on `list_issues`/`list_pull_requests` semantics (via `gitea-issues`/`gitea-prs`), its ambiguous-number resolution relies on `issue_read`'s `is_pull` field, and its domain-skill index groups that tool surface by owning skill — all verified against this source at authoring time. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/status-checkin.md, references/number-resolution.md, references/skill-index.md - **Status:** `extracted` ## gitea-mcp-slim-go @@ -13,7 +13,7 @@ - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/issue/slim.go - **Description:** Slim response shape structs from gitea-mcp source — confirms `is_pull` is present on a single-item `issue_read` response, the field this skill's resolution step depends on to distinguish an issue from a PR sharing the same number. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/number-resolution.md - **Status:** `extracted` ## context7-websites-gitea @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 — confirms issues and pull requests share a single per-repository number sequence, and that Gitea returns 404 for permission failures rather than a distinct 403, both facts this skill's resolution and error-handling steps depend on. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md +- **Contributing files:** references/number-resolution.md - **Status:** `extracted` ## context7-gitea-tea-cli diff --git a/plugins/gitea/skills/gitea-workflow/references/status-checkin.md b/plugins/gitea/skills/gitea-workflow/references/status-checkin.md new file mode 100644 index 0000000..987ce6f --- /dev/null +++ b/plugins/gitea/skills/gitea-workflow/references/status-checkin.md @@ -0,0 +1,15 @@ +--- +topic: status-checkin +source_keys: + - gitea-mcp-repo +--- + +# Status check-in (invoked with no request) + +Give a repo status check-in: + +1. Invoke `gitea-issues` to list open issues (`state: "open"`). +2. Invoke `gitea-prs` to list open PRs (`state: "open"`). Run this alongside step 1 — the two are independent reads, so serialising them only adds latency. +3. Report as two sections, "Open Issues" and "Open Pull Requests", each a compact list of number and title. + +Then report per `SKILL.md`'s Report section. -- 2.43.0 From f3b4860e14534734176e81ceacebbc89284ffcf4 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 14/89] refactor(git-commits): retrofit to the ADR-0020 context contract Description 724 -> 214 chars, body 1102 -> 343 words, Gotchas 12 -> 3. Create, rewrite, and cherry-pick flows move to self-contained references/ files behind a dispatch table. The audit caught that moving the secret scan into the create flow left the amend/squash path with no check in its loaded context; it is now a gate common to every flow. Also re-homes the interactive-rebase reflog warning git-history dropped, since this skill owns rebase. --- plugins/git/.apm/skills/git-commits/README.md | 17 ++- plugins/git/.apm/skills/git-commits/SKILL.md | 110 +++++------------- .../git-commits/references/cherry-pick.md | 12 ++ .../git-commits/references/commit-template.md | 4 +- .../references/conventional-commits-spec.md | 24 ++-- .../git-commits/references/create-commit.md | 17 +++ .../git-commits/references/rewrite-history.md | 37 ++++++ .../skills/git-commits/references/sources.md | 16 +-- plugins/git/skills/git-commits/README.md | 17 ++- plugins/git/skills/git-commits/SKILL.md | 110 +++++------------- .../git-commits/references/cherry-pick.md | 12 ++ .../git-commits/references/commit-template.md | 4 +- .../references/conventional-commits-spec.md | 24 ++-- .../git-commits/references/create-commit.md | 17 +++ .../git-commits/references/rewrite-history.md | 37 ++++++ .../skills/git-commits/references/sources.md | 16 +-- 16 files changed, 260 insertions(+), 214 deletions(-) create mode 100644 plugins/git/.apm/skills/git-commits/references/cherry-pick.md create mode 100644 plugins/git/.apm/skills/git-commits/references/create-commit.md create mode 100644 plugins/git/.apm/skills/git-commits/references/rewrite-history.md create mode 100644 plugins/git/skills/git-commits/references/cherry-pick.md create mode 100644 plugins/git/skills/git-commits/references/create-commit.md create mode 100644 plugins/git/skills/git-commits/references/rewrite-history.md diff --git a/plugins/git/.apm/skills/git-commits/README.md b/plugins/git/.apm/skills/git-commits/README.md index 228d96f..932931b 100644 --- a/plugins/git/.apm/skills/git-commits/README.md +++ b/plugins/git/.apm/skills/git-commits/README.md @@ -16,9 +16,16 @@ Describe your commit task: create a new commit, amend, squash, or cherry-pick. T ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/conventional-commits-spec.md` | Full Conventional Commits specification | -| `references/commit-template.md` | Why / Implementation Notes / Impact body structure and full trailer list | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — gotchas, the flow dispatch table, the gates common to every flow, and the output shape | +| `references/create-commit.md` | Composing a new commit from staged changes | +| `references/rewrite-history.md` | Amending, squashing, or folding a `fixup!`/`squash!` commit into an earlier one | +| `references/cherry-pick.md` | Replaying an existing commit onto the current branch | +| `references/conventional-commits-spec.md` | A type, footer, or breaking-change edge case is not obvious — full spec, 11-type set, commitlint constraint table | +| `references/commit-template.md` | Writing a body for a non-trivial commit — Why / Implementation Notes / Impact structure and the full trailer list | | `references/sources.md` | Research sources and provenance | + +## Composition + +Part of the git plugin's domain suite. This skill owns commit authoring and history-rewriting operations only; `git-history` inspects history, `git-branches` owns branch lifecycle, and `git-workflow` is the conversational entry point that routes between them. diff --git a/plugins/git/.apm/skills/git-commits/SKILL.md b/plugins/git/.apm/skills/git-commits/SKILL.md index 1aef4eb..78b399f 100644 --- a/plugins/git/.apm/skills/git-commits/SKILL.md +++ b/plugins/git/.apm/skills/git-commits/SKILL.md @@ -2,16 +2,13 @@ name: git-commits description: > - Use when creating, amending, squashing, or cherry-picking commits. - Generates well-formatted commit messages following Conventional Commits spec (type, scope, description, body, footers). - Validates against commitlint config-conventional constraints (header max 100 chars, lowercase subject, no trailing periods, type must be one of 11 standard types). - Communicates SemVer impact (MAJOR for breaking changes, MINOR for features, PATCH for fixes). - Handles confirmation gates for history-altering operations (amend, rebase, squash). - Provides interactive guidance for humans, structured JSON output for agents. - Do not use for: inspecting git history, branch management, or repository state inspection — those are separate skills. + Use when creating, amending, squashing, or cherry-picking commits, including + writing and validating the Conventional Commits message. + Not history inspection -> `git-history`. + Not branch lifecycle -> `git-branches`. metadata: - version: "0.1.2" + version: "0.1.3" category: git source_keys: - conventional-commits-spec @@ -24,92 +21,45 @@ allowed-tools: Bash ## Gotchas -- **Type must be one of 11 standard types** — `feat`, `fix`, `perf`, `revert`, `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore`. Non-standard types will fail commitlint validation. Note: the Conventional Commits spec itself only mandates `feat`/`fix` — the 11-type set is a commitlint/Angular convention this skill validates against, not a spec requirement. -- **Scope is optional but should be used** — helps identify which part of the system changed. Examples: `api`, `db`, `cli`, `config`. -- **Header max 100 characters** — type + scope + colon + description must fit. If longer, move detail to body. -- **BREAKING CHANGE notation** — use `!` before the colon (`feat!: drop Node 6`) for visibility in `git log --oneline`. Footer notation (`BREAKING CHANGE: ...`) is machine-readable but hidden in log. -- **SemVer mapping is not optional** — agents must communicate: `feat` → MINOR bump, `fix`/`perf`/`revert` → PATCH, any with breaking change → MAJOR. -- **Confirmation gates are mandatory for destructive operations** — amend, rebase, squash require explicit user/agent approval before execution. -- **Never skip hooks with `--no-verify`** — hooks are the automated QA gate; bypassing them breaks the pipeline. Do not add this flag to any commit command unless the user explicitly demands it, and warn them if they do. -- **Never force-push `main`/`master`** — even after an amend or interactive rebase, refuse to force-push a protected branch (`main`, `master`) and explain why; force-push is only safe on branches no one else has based work on. -- **Command examples use the `rtk git` wrapper** — this org's convention routes all git invocations through `rtk git <subcommand>` instead of bare `git <subcommand>`. Follow this prefix in any command you actually run. -- **Never commit secrets, credentials, or environment-specific config** — if staged changes contain what looks like an API key, token, password, or connection string, stop and flag it before committing rather than committing it. -- **Commits must be atomic and leave the repo working** — each commit should be one logical, independently reviewable and reversible change, and should leave the repository in a buildable/testable state. If staged changes bundle unrelated work, suggest splitting before committing. -- **Commit messages explain why, not what** — the diff already shows what changed; the message's job is to capture context the diff can't (motivation, root cause, tradeoffs). See `references/commit-template.md` for the structure this maps to. +- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. +- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it. +- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning. -## Workflow +## Dispatch -### For creating a new commit: +Read exactly one flow file. Each is self-contained. -1. **Gather context** — what changed and why? (from staged changes, PR description, issue context). Verify the staged diff is one logical, atomic change and that the repo would still build/test at this commit — if not, suggest splitting before proceeding. -2. **Check for secrets** — scan the staged diff for anything that looks like a credential, API key, token, or environment-specific config. Stop and flag it rather than committing. -3. **Determine type** — is this a feature (`feat`), bug fix (`fix`), or other? Default: check the change itself. -4. **Determine scope** — which system/module? Use scope from plugin config if set, otherwise infer from files changed. -5. **Write description** — imperative mood, no period. Neither source spec sets a length target below the 100-char header max, but convention favors keeping it to ~50 characters where possible for `git log --oneline` readability. Examples: "add user authentication", "fix race condition in cache". -6. **Add body if needed** — explain why (not what). Blank line before body, wrap at 100 chars. For non-trivial changes, follow the Why / Implementation Notes / Impact structure in `references/commit-template.md`. -7. **Add footers if needed** — `Fixes: #123`, `Refs: #123`, `ADR: 0012`, `RFC: 0003`, `Design: <link>`, `Reviewed-by: Name`, `Co-authored-by: Name <email>`, `Signed-off-by: Name <email>`, `BREAKING CHANGE: description`. See `references/commit-template.md` for the full trailer list. -8. **Validate** — check header length, type correctness, no trailing periods, lowercase. -9. **Confirm and execute** — for agents, require explicit approval; for humans, show preview and ask. Never add `--no-verify` to skip hooks. +| Condition | Flow | Read | +|---|---|---| +| Composing a new commit from staged changes | create | `references/create-commit.md` | +| Amending, squashing, or folding a fixup into an earlier commit | rewrite | `references/rewrite-history.md` | +| Replaying an existing commit onto the current branch | cherry-pick | `references/cherry-pick.md` | -### For amending a commit: +## Gates on every flow -1. **Stage new changes** (or changes to undo) -2. **Run amend operation** — executes `rtk git commit --amend [--no-edit]` based on user intent -3. **Offer message edit** — if user wants to change commit message, show current message and prompt for new one -4. **Confirm before force-push** — amending is only safe on non-shared branches; if the current branch is `main`/`master`, refuse to force-push and explain why rather than warning and proceeding +1. **Confirmation.** No history rewrite executes without explicit approval from the user or the calling agent. Cherry-pick needs the destination branch confirmed first. +2. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, + token, password, connection string, or environment-specific config. Stop and flag it rather than + committing it. +3. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. +4. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. +5. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. -### For squashing commits (interactive rebase): +## Output -1. **Identify commits to squash** — typically the last N commits on current branch -2. **Confirm operation** — squashing rewrites history; get explicit approval -3. **Execute rebase** — `rtk git rebase -i HEAD~N`, mark older commits as `squash` or `fixup` -4. **Handle merge conflicts** — if rebase halts, offer conflict resolution options or abort; do not resolve automatically without confirmation -5. **Offer message composition** — if squashing interactive, allow message editing - -### For squashing commits (autosquash — preferred when tagging at commit time): - -Prefer this over manual interactive rebase when a commit is written to be folded into an earlier one, since it removes the manual "mark as squash/fixup" step and the risk of reordering the wrong line: - -1. **Create the fixup/squash commit** — `rtk git commit --fixup=<commit>` (keeps target's message) or `rtk git commit --squash=<commit>` (lets you edit the combined message later). Both prefix the message with `fixup!`/`squash!` and target `<commit>`. -2. **Confirm operation** — rewriting history still requires explicit approval before the rebase runs. -3. **Execute** — `rtk git rebase --autosquash HEAD~N` (or `-i --autosquash` to review the plan first); git reorders and marks the `fixup!`/`squash!` commits against their targets automatically. -4. **Handle merge conflicts** — same as manual rebase: offer resolution or abort, never resolve automatically without confirmation. - -### For cherry-picking: - -1. **Identify source commit(s)** — hash or branch reference -2. **Confirm destination branch** — cherry-pick will replay commits on current branch -3. **Execute cherry-pick** — `rtk git cherry-pick <commit-hash>` -4. **Handle conflicts** — offer conflict resolution or abort -5. **Report outcome** — successful replays, conflicts, or rejected commits - -## Output format (for agent consumption) - -Return structured JSON: +For an agent caller, return: ```json { "operation": "create|amend|squash|cherry-pick", "status": "success|conflict|rejected", - "message": "Commit message or error description", + "message": "commit message or error description", "commit_hash": "abc1234", "semver_impact": "MAJOR|MINOR|PATCH|none", - "breaking_change": true|false, - "confirmation_required": true|false, - "details": { - "type": "feat", - "scope": "api", - "description": "add user authentication", - "body": "optional body text", - "footers": ["Fixes: #123", "Refs: #456", "ADR: 0012", "Reviewed-by: Alice", "Co-authored-by: Bob <bob@example.com>", "Signed-off-by: Alice <alice@example.com>"] - } + "breaking_change": false, + "confirmation_required": false, + "details": { "type": "feat", "scope": "api", "description": "add user authentication" } } ``` -For interactive human use, format as readable prose with clear prompts and previews. - -## Reference - -If a footer or type/scope edge case isn't covered above, read `references/conventional-commits-spec.md` for the full specification. - -For the Why / Implementation Notes / Impact body structure and the full trailer list, read `references/commit-template.md`. +For a human caller, show the same fields as a prose preview with a confirmation prompt. diff --git a/plugins/git/.apm/skills/git-commits/references/cherry-pick.md b/plugins/git/.apm/skills/git-commits/references/cherry-pick.md new file mode 100644 index 0000000..c5018ea --- /dev/null +++ b/plugins/git/.apm/skills/git-commits/references/cherry-pick.md @@ -0,0 +1,12 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Cherry-picking a commit + +1. **Identify the source commit** — a hash or a branch reference. +2. **Confirm the destination** — cherry-pick replays onto the branch currently checked out, so verify that is the intended branch before running anything. +3. **Execute** — `rtk git cherry-pick <commit-hash>`. A range is `<a>..<b>` (exclusive of `<a>`) or `<a>^..<b>` (inclusive); `-n` stages without committing, for when the replay needs editing first. +4. **Handle conflicts** — if the replay halts, offer resolution or `rtk git cherry-pick --abort`. Never resolve automatically without confirmation. +5. **Report the outcome** — which commits replayed, which conflicted, and which were rejected. diff --git a/plugins/git/.apm/skills/git-commits/references/commit-template.md b/plugins/git/.apm/skills/git-commits/references/commit-template.md index 2c504a8..885d686 100644 --- a/plugins/git/.apm/skills/git-commits/references/commit-template.md +++ b/plugins/git/.apm/skills/git-commits/references/commit-template.md @@ -7,7 +7,7 @@ source_keys: Use this structure for the body/footer of any non-trivial commit (skip sections that don't apply — do not leave placeholders in the actual commit). -``` +```text <type>(<scope>): <concise summary> ``` The header is required. Describe the intended outcome, not the implementation. @@ -53,7 +53,7 @@ Omit if there are no noteworthy impacts. Structured metadata for traceability and tooling. Use only the trailers that apply: -``` +```text Fixes: Refs: ADR: diff --git a/plugins/git/.apm/skills/git-commits/references/conventional-commits-spec.md b/plugins/git/.apm/skills/git-commits/references/conventional-commits-spec.md index 3c41010..77f1528 100644 --- a/plugins/git/.apm/skills/git-commits/references/conventional-commits-spec.md +++ b/plugins/git/.apm/skills/git-commits/references/conventional-commits-spec.md @@ -10,7 +10,7 @@ Conventional Commits is a lightweight convention on top of commit messages that ## Message Format -``` +```text <type>[optional scope]: <description> [optional body] @@ -58,20 +58,20 @@ A `BREAKING CHANGE` footer or `!` on **any** type always triggers a MAJOR bump. Two equivalent notations: **`!` in header** (preferred — visible in `git log --oneline`): -``` +```text feat!: drop support for Node 6 feat(api)!: remove deprecated endpoint ``` **`BREAKING CHANGE` footer** (machine-readable body): -``` +```text feat: allow config to extend other configs BREAKING CHANGE: `extends` key now used for extending config files ``` **Both together** (most explicit): -``` +```text feat!: drop support for Node 6 BREAKING CHANGE: use JavaScript features not available in Node 6. @@ -85,7 +85,7 @@ Rules: ## Footer Token Rules -``` +```text <token>: <value> <token> #<value> # for issue references ``` @@ -96,7 +96,7 @@ Rules: - Blank line required before the footer block. Valid footer examples: -``` +```text Reviewed-by: Z Refs: #123 Co-authored-by: Alice <alice@example.com> @@ -106,29 +106,29 @@ BREAKING CHANGE: the `--format` flag now requires a value ## Examples Minimal — no body, no footer: -``` +```text docs: correct spelling of CHANGELOG ``` With scope: -``` +```text feat(lang): add Polish language ``` Breaking change via `!`: -``` +```text feat!: send an email to the customer when a product is shipped ``` Breaking change via footer: -``` +```text feat: allow provided config object to extend other configs BREAKING CHANGE: `extends` key in config file is now used for extending other config files ``` Multi-paragraph body with multiple footers: -``` +```text fix: prevent racing of requests Introduce a request id and a reference to latest request. Dismiss @@ -142,7 +142,7 @@ Refs: #123 ``` Revert: -``` +```text revert: let us never again speak of the noodle incident Refs: 676104e, a215868 diff --git a/plugins/git/.apm/skills/git-commits/references/create-commit.md b/plugins/git/.apm/skills/git-commits/references/create-commit.md new file mode 100644 index 0000000..d2bdfe1 --- /dev/null +++ b/plugins/git/.apm/skills/git-commits/references/create-commit.md @@ -0,0 +1,17 @@ +--- +source_keys: + - conventional-commits-spec + - commitlint-config-conventional + - org-commit-conventions +--- + +# Creating a new commit + +1. **Gather context** — what changed and why, from the staged diff, the PR description, or the issue. Confirm the staged diff is one logical, independently reviewable and reversible change that leaves the repository buildable and testable. If it bundles unrelated work, suggest splitting it before going further. +2. **Determine the type** — read it off the change itself: a new user-visible feature is `feat`, a bug fix is `fix`. For the full 11-type set and each type's SemVer impact, read `references/conventional-commits-spec.md`. +3. **Determine the scope** — use the scope from plugin config where one is set, otherwise infer it from the files changed (`api`, `db`, `cli`, `config`). Scope is optional, but it identifies which part of the system moved and is worth setting. +4. **Write the description** — imperative mood, no trailing period: "add user authentication", "fix race condition in cache". Neither source spec sets a target below the 100-character header maximum, but convention favours roughly 50 characters so `git log --oneline` stays readable. +5. **Add a body when the change is non-trivial** — blank line first, wrapped at 100 characters. Explain *why*, not what: the diff already shows what changed, and the message's job is the context the diff cannot carry — motivation, root cause, tradeoffs. Follow the Why / Implementation Notes / Impact structure in `references/commit-template.md`. +6. **Add footers where they apply** — `Fixes: #123`, `Refs: #123`, `ADR: 0012`, `Co-authored-by: Name <email>`, `BREAKING CHANGE: description`. For the full trailer list, read `references/commit-template.md`. +7. **Signal a breaking change with `!` before the colon** — `feat!: drop Node 6` is visible in `git log --oneline`, where the `BREAKING CHANGE:` footer alone is machine-readable but hidden. Use both when the break needs describing. +8. **Validate, confirm, execute** — check header length, type, lowercase subject and trailing period against commitlint, show the message, and commit only once the caller has approved. Never add `--no-verify`. diff --git a/plugins/git/.apm/skills/git-commits/references/rewrite-history.md b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md new file mode 100644 index 0000000..942f44f --- /dev/null +++ b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md @@ -0,0 +1,37 @@ +--- +source_keys: + - org-commit-conventions + - context7-git-htmldocs +--- + +# Rewriting existing commits + +Every flow on this page rewrites history. None of them runs before the caller has explicitly approved it, and none is followed by a force-push to `main`/`master` — refuse that and explain why instead. + +## Amend the last commit + +1. Stage the new changes, or the changes that undo something. +2. Run `rtk git commit --amend`, adding `--no-edit` when the message stays as it is. +3. If the message should change, show the current one and prompt for the replacement. +4. The branch has now diverged from its remote. Amending is safe only on a branch nobody else has based work on; on `main`/`master`, refuse the force-push and explain, rather than warning and proceeding. + +## Fold a commit into an earlier one (autosquash — preferred) + +Prefer this whenever a commit is written to be folded, because git does the marking: + +1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit. +2. Get explicit approval — the rebase still rewrites history. +3. Run `rtk git rebase --autosquash HEAD~N`, or `-i --autosquash` to review the plan first. Git reorders the tagged commits against their targets automatically. + +## Squash by hand (interactive rebase) + +Use this when the commits were not tagged at commit time. **Interactive rebase has no undo once `rebase -i` starts — `git reflog` is the recovery path.** + +1. Identify the commits to squash — typically the last N on the current branch. +2. Get explicit approval. +3. Run `rtk git rebase -i HEAD~N`, marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them. +4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`. + +## When a rebase halts on a conflict + +Offer conflict resolution or `rtk git rebase --abort`. Do not resolve conflicts automatically without confirmation. diff --git a/plugins/git/.apm/skills/git-commits/references/sources.md b/plugins/git/.apm/skills/git-commits/references/sources.md index fdd4d36..960d8b3 100644 --- a/plugins/git/.apm/skills/git-commits/references/sources.md +++ b/plugins/git/.apm/skills/git-commits/references/sources.md @@ -7,34 +7,34 @@ source_keys: - context7-git-htmldocs --- -# Research Sources for git:commits Skill +# Research Sources for git-commits Skill -Sources extracted from the git plugin research phase. Only sources that directly informed this skill are listed; sibling skills (git:branches, git:history, git:remotes, etc.) have their own sources.md. +Sources extracted from the git plugin research phase. Only sources that directly informed this skill are listed; sibling skills (git-branches, git-history, git-remotes, etc.) have their own sources.md. ## conventional-commits-spec - **Description:** Conventional Commits Specification (v1.0.0) — message format, types, breaking changes, footer rules - **Research doc:** plugins/git/docs/research/docs/git/commits.md § "Conventional Commits Specification (v1.0.0)" -- **Contributing files:** SKILL.md, references/conventional-commits-spec.md +- **Contributing files:** SKILL.md, references/conventional-commits-spec.md, references/create-commit.md - **Status:** extracted ## commitlint-config-conventional - **Description:** commitlint config-conventional preset — validation constraints (max 100 chars header, no trailing periods, lowercase type, 11-type set enforcement) - **Research doc:** plugins/git/docs/research/docs/git/commits.md § "commitlint Constraints (`config-conventional`)" -- **Contributing files:** SKILL.md, references/conventional-commits-spec.md +- **Contributing files:** SKILL.md, references/conventional-commits-spec.md, references/create-commit.md - **Status:** extracted ## org-commit-conventions - **Description:** Organization commit message body template and git conventions (atomic commits, no `--no-verify`, no force-push main/master, `rtk git` wrapper) — content fully embedded in this skill; the org's `core/instructions/git.md` and `core/instructions/commits.md` are provenance only and are not a live dependency - **Research doc:** core/instructions/commits.md, core/instructions/git.md (org convention, not part of the plugin's research corpus) -- **Contributing files:** SKILL.md, references/commit-template.md +- **Contributing files:** SKILL.md, references/commit-template.md, references/create-commit.md, references/rewrite-history.md - **Status:** extracted ## context7-git-htmldocs -- **Description:** Official Git HTML documentation — `git commit --squash`/`--fixup` and `git rebase --autosquash` flag semantics -- **Research doc:** plugins/git/docs/research/docs/git/cli-reference.md -- **Contributing files:** SKILL.md +- **Description:** Official Git HTML documentation — `git commit --squash`/`--fixup`, `git rebase --autosquash`, and `git cherry-pick` range and abort semantics +- **Research doc:** plugins/git/docs/research/docs/git/cli-reference.md § "Committing", § "Rebasing", § "Cherry-picking" +- **Contributing files:** SKILL.md, references/rewrite-history.md, references/cherry-pick.md - **Status:** extracted diff --git a/plugins/git/skills/git-commits/README.md b/plugins/git/skills/git-commits/README.md index 228d96f..932931b 100644 --- a/plugins/git/skills/git-commits/README.md +++ b/plugins/git/skills/git-commits/README.md @@ -16,9 +16,16 @@ Describe your commit task: create a new commit, amend, squash, or cherry-pick. T ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/conventional-commits-spec.md` | Full Conventional Commits specification | -| `references/commit-template.md` | Why / Implementation Notes / Impact body structure and full trailer list | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — gotchas, the flow dispatch table, the gates common to every flow, and the output shape | +| `references/create-commit.md` | Composing a new commit from staged changes | +| `references/rewrite-history.md` | Amending, squashing, or folding a `fixup!`/`squash!` commit into an earlier one | +| `references/cherry-pick.md` | Replaying an existing commit onto the current branch | +| `references/conventional-commits-spec.md` | A type, footer, or breaking-change edge case is not obvious — full spec, 11-type set, commitlint constraint table | +| `references/commit-template.md` | Writing a body for a non-trivial commit — Why / Implementation Notes / Impact structure and the full trailer list | | `references/sources.md` | Research sources and provenance | + +## Composition + +Part of the git plugin's domain suite. This skill owns commit authoring and history-rewriting operations only; `git-history` inspects history, `git-branches` owns branch lifecycle, and `git-workflow` is the conversational entry point that routes between them. diff --git a/plugins/git/skills/git-commits/SKILL.md b/plugins/git/skills/git-commits/SKILL.md index 1aef4eb..78b399f 100644 --- a/plugins/git/skills/git-commits/SKILL.md +++ b/plugins/git/skills/git-commits/SKILL.md @@ -2,16 +2,13 @@ name: git-commits description: > - Use when creating, amending, squashing, or cherry-picking commits. - Generates well-formatted commit messages following Conventional Commits spec (type, scope, description, body, footers). - Validates against commitlint config-conventional constraints (header max 100 chars, lowercase subject, no trailing periods, type must be one of 11 standard types). - Communicates SemVer impact (MAJOR for breaking changes, MINOR for features, PATCH for fixes). - Handles confirmation gates for history-altering operations (amend, rebase, squash). - Provides interactive guidance for humans, structured JSON output for agents. - Do not use for: inspecting git history, branch management, or repository state inspection — those are separate skills. + Use when creating, amending, squashing, or cherry-picking commits, including + writing and validating the Conventional Commits message. + Not history inspection -> `git-history`. + Not branch lifecycle -> `git-branches`. metadata: - version: "0.1.2" + version: "0.1.3" category: git source_keys: - conventional-commits-spec @@ -24,92 +21,45 @@ allowed-tools: Bash ## Gotchas -- **Type must be one of 11 standard types** — `feat`, `fix`, `perf`, `revert`, `docs`, `style`, `refactor`, `test`, `build`, `ci`, `chore`. Non-standard types will fail commitlint validation. Note: the Conventional Commits spec itself only mandates `feat`/`fix` — the 11-type set is a commitlint/Angular convention this skill validates against, not a spec requirement. -- **Scope is optional but should be used** — helps identify which part of the system changed. Examples: `api`, `db`, `cli`, `config`. -- **Header max 100 characters** — type + scope + colon + description must fit. If longer, move detail to body. -- **BREAKING CHANGE notation** — use `!` before the colon (`feat!: drop Node 6`) for visibility in `git log --oneline`. Footer notation (`BREAKING CHANGE: ...`) is machine-readable but hidden in log. -- **SemVer mapping is not optional** — agents must communicate: `feat` → MINOR bump, `fix`/`perf`/`revert` → PATCH, any with breaking change → MAJOR. -- **Confirmation gates are mandatory for destructive operations** — amend, rebase, squash require explicit user/agent approval before execution. -- **Never skip hooks with `--no-verify`** — hooks are the automated QA gate; bypassing them breaks the pipeline. Do not add this flag to any commit command unless the user explicitly demands it, and warn them if they do. -- **Never force-push `main`/`master`** — even after an amend or interactive rebase, refuse to force-push a protected branch (`main`, `master`) and explain why; force-push is only safe on branches no one else has based work on. -- **Command examples use the `rtk git` wrapper** — this org's convention routes all git invocations through `rtk git <subcommand>` instead of bare `git <subcommand>`. Follow this prefix in any command you actually run. -- **Never commit secrets, credentials, or environment-specific config** — if staged changes contain what looks like an API key, token, password, or connection string, stop and flag it before committing rather than committing it. -- **Commits must be atomic and leave the repo working** — each commit should be one logical, independently reviewable and reversible change, and should leave the repository in a buildable/testable state. If staged changes bundle unrelated work, suggest splitting before committing. -- **Commit messages explain why, not what** — the diff already shows what changed; the message's job is to capture context the diff can't (motivation, root cause, tradeoffs). See `references/commit-template.md` for the structure this maps to. +- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. +- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it. +- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning. -## Workflow +## Dispatch -### For creating a new commit: +Read exactly one flow file. Each is self-contained. -1. **Gather context** — what changed and why? (from staged changes, PR description, issue context). Verify the staged diff is one logical, atomic change and that the repo would still build/test at this commit — if not, suggest splitting before proceeding. -2. **Check for secrets** — scan the staged diff for anything that looks like a credential, API key, token, or environment-specific config. Stop and flag it rather than committing. -3. **Determine type** — is this a feature (`feat`), bug fix (`fix`), or other? Default: check the change itself. -4. **Determine scope** — which system/module? Use scope from plugin config if set, otherwise infer from files changed. -5. **Write description** — imperative mood, no period. Neither source spec sets a length target below the 100-char header max, but convention favors keeping it to ~50 characters where possible for `git log --oneline` readability. Examples: "add user authentication", "fix race condition in cache". -6. **Add body if needed** — explain why (not what). Blank line before body, wrap at 100 chars. For non-trivial changes, follow the Why / Implementation Notes / Impact structure in `references/commit-template.md`. -7. **Add footers if needed** — `Fixes: #123`, `Refs: #123`, `ADR: 0012`, `RFC: 0003`, `Design: <link>`, `Reviewed-by: Name`, `Co-authored-by: Name <email>`, `Signed-off-by: Name <email>`, `BREAKING CHANGE: description`. See `references/commit-template.md` for the full trailer list. -8. **Validate** — check header length, type correctness, no trailing periods, lowercase. -9. **Confirm and execute** — for agents, require explicit approval; for humans, show preview and ask. Never add `--no-verify` to skip hooks. +| Condition | Flow | Read | +|---|---|---| +| Composing a new commit from staged changes | create | `references/create-commit.md` | +| Amending, squashing, or folding a fixup into an earlier commit | rewrite | `references/rewrite-history.md` | +| Replaying an existing commit onto the current branch | cherry-pick | `references/cherry-pick.md` | -### For amending a commit: +## Gates on every flow -1. **Stage new changes** (or changes to undo) -2. **Run amend operation** — executes `rtk git commit --amend [--no-edit]` based on user intent -3. **Offer message edit** — if user wants to change commit message, show current message and prompt for new one -4. **Confirm before force-push** — amending is only safe on non-shared branches; if the current branch is `main`/`master`, refuse to force-push and explain why rather than warning and proceeding +1. **Confirmation.** No history rewrite executes without explicit approval from the user or the calling agent. Cherry-pick needs the destination branch confirmed first. +2. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, + token, password, connection string, or environment-specific config. Stop and flag it rather than + committing it. +3. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. +4. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. +5. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. -### For squashing commits (interactive rebase): +## Output -1. **Identify commits to squash** — typically the last N commits on current branch -2. **Confirm operation** — squashing rewrites history; get explicit approval -3. **Execute rebase** — `rtk git rebase -i HEAD~N`, mark older commits as `squash` or `fixup` -4. **Handle merge conflicts** — if rebase halts, offer conflict resolution options or abort; do not resolve automatically without confirmation -5. **Offer message composition** — if squashing interactive, allow message editing - -### For squashing commits (autosquash — preferred when tagging at commit time): - -Prefer this over manual interactive rebase when a commit is written to be folded into an earlier one, since it removes the manual "mark as squash/fixup" step and the risk of reordering the wrong line: - -1. **Create the fixup/squash commit** — `rtk git commit --fixup=<commit>` (keeps target's message) or `rtk git commit --squash=<commit>` (lets you edit the combined message later). Both prefix the message with `fixup!`/`squash!` and target `<commit>`. -2. **Confirm operation** — rewriting history still requires explicit approval before the rebase runs. -3. **Execute** — `rtk git rebase --autosquash HEAD~N` (or `-i --autosquash` to review the plan first); git reorders and marks the `fixup!`/`squash!` commits against their targets automatically. -4. **Handle merge conflicts** — same as manual rebase: offer resolution or abort, never resolve automatically without confirmation. - -### For cherry-picking: - -1. **Identify source commit(s)** — hash or branch reference -2. **Confirm destination branch** — cherry-pick will replay commits on current branch -3. **Execute cherry-pick** — `rtk git cherry-pick <commit-hash>` -4. **Handle conflicts** — offer conflict resolution or abort -5. **Report outcome** — successful replays, conflicts, or rejected commits - -## Output format (for agent consumption) - -Return structured JSON: +For an agent caller, return: ```json { "operation": "create|amend|squash|cherry-pick", "status": "success|conflict|rejected", - "message": "Commit message or error description", + "message": "commit message or error description", "commit_hash": "abc1234", "semver_impact": "MAJOR|MINOR|PATCH|none", - "breaking_change": true|false, - "confirmation_required": true|false, - "details": { - "type": "feat", - "scope": "api", - "description": "add user authentication", - "body": "optional body text", - "footers": ["Fixes: #123", "Refs: #456", "ADR: 0012", "Reviewed-by: Alice", "Co-authored-by: Bob <bob@example.com>", "Signed-off-by: Alice <alice@example.com>"] - } + "breaking_change": false, + "confirmation_required": false, + "details": { "type": "feat", "scope": "api", "description": "add user authentication" } } ``` -For interactive human use, format as readable prose with clear prompts and previews. - -## Reference - -If a footer or type/scope edge case isn't covered above, read `references/conventional-commits-spec.md` for the full specification. - -For the Why / Implementation Notes / Impact body structure and the full trailer list, read `references/commit-template.md`. +For a human caller, show the same fields as a prose preview with a confirmation prompt. diff --git a/plugins/git/skills/git-commits/references/cherry-pick.md b/plugins/git/skills/git-commits/references/cherry-pick.md new file mode 100644 index 0000000..c5018ea --- /dev/null +++ b/plugins/git/skills/git-commits/references/cherry-pick.md @@ -0,0 +1,12 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Cherry-picking a commit + +1. **Identify the source commit** — a hash or a branch reference. +2. **Confirm the destination** — cherry-pick replays onto the branch currently checked out, so verify that is the intended branch before running anything. +3. **Execute** — `rtk git cherry-pick <commit-hash>`. A range is `<a>..<b>` (exclusive of `<a>`) or `<a>^..<b>` (inclusive); `-n` stages without committing, for when the replay needs editing first. +4. **Handle conflicts** — if the replay halts, offer resolution or `rtk git cherry-pick --abort`. Never resolve automatically without confirmation. +5. **Report the outcome** — which commits replayed, which conflicted, and which were rejected. diff --git a/plugins/git/skills/git-commits/references/commit-template.md b/plugins/git/skills/git-commits/references/commit-template.md index 2c504a8..885d686 100644 --- a/plugins/git/skills/git-commits/references/commit-template.md +++ b/plugins/git/skills/git-commits/references/commit-template.md @@ -7,7 +7,7 @@ source_keys: Use this structure for the body/footer of any non-trivial commit (skip sections that don't apply — do not leave placeholders in the actual commit). -``` +```text <type>(<scope>): <concise summary> ``` The header is required. Describe the intended outcome, not the implementation. @@ -53,7 +53,7 @@ Omit if there are no noteworthy impacts. Structured metadata for traceability and tooling. Use only the trailers that apply: -``` +```text Fixes: Refs: ADR: diff --git a/plugins/git/skills/git-commits/references/conventional-commits-spec.md b/plugins/git/skills/git-commits/references/conventional-commits-spec.md index 3c41010..77f1528 100644 --- a/plugins/git/skills/git-commits/references/conventional-commits-spec.md +++ b/plugins/git/skills/git-commits/references/conventional-commits-spec.md @@ -10,7 +10,7 @@ Conventional Commits is a lightweight convention on top of commit messages that ## Message Format -``` +```text <type>[optional scope]: <description> [optional body] @@ -58,20 +58,20 @@ A `BREAKING CHANGE` footer or `!` on **any** type always triggers a MAJOR bump. Two equivalent notations: **`!` in header** (preferred — visible in `git log --oneline`): -``` +```text feat!: drop support for Node 6 feat(api)!: remove deprecated endpoint ``` **`BREAKING CHANGE` footer** (machine-readable body): -``` +```text feat: allow config to extend other configs BREAKING CHANGE: `extends` key now used for extending config files ``` **Both together** (most explicit): -``` +```text feat!: drop support for Node 6 BREAKING CHANGE: use JavaScript features not available in Node 6. @@ -85,7 +85,7 @@ Rules: ## Footer Token Rules -``` +```text <token>: <value> <token> #<value> # for issue references ``` @@ -96,7 +96,7 @@ Rules: - Blank line required before the footer block. Valid footer examples: -``` +```text Reviewed-by: Z Refs: #123 Co-authored-by: Alice <alice@example.com> @@ -106,29 +106,29 @@ BREAKING CHANGE: the `--format` flag now requires a value ## Examples Minimal — no body, no footer: -``` +```text docs: correct spelling of CHANGELOG ``` With scope: -``` +```text feat(lang): add Polish language ``` Breaking change via `!`: -``` +```text feat!: send an email to the customer when a product is shipped ``` Breaking change via footer: -``` +```text feat: allow provided config object to extend other configs BREAKING CHANGE: `extends` key in config file is now used for extending other config files ``` Multi-paragraph body with multiple footers: -``` +```text fix: prevent racing of requests Introduce a request id and a reference to latest request. Dismiss @@ -142,7 +142,7 @@ Refs: #123 ``` Revert: -``` +```text revert: let us never again speak of the noodle incident Refs: 676104e, a215868 diff --git a/plugins/git/skills/git-commits/references/create-commit.md b/plugins/git/skills/git-commits/references/create-commit.md new file mode 100644 index 0000000..d2bdfe1 --- /dev/null +++ b/plugins/git/skills/git-commits/references/create-commit.md @@ -0,0 +1,17 @@ +--- +source_keys: + - conventional-commits-spec + - commitlint-config-conventional + - org-commit-conventions +--- + +# Creating a new commit + +1. **Gather context** — what changed and why, from the staged diff, the PR description, or the issue. Confirm the staged diff is one logical, independently reviewable and reversible change that leaves the repository buildable and testable. If it bundles unrelated work, suggest splitting it before going further. +2. **Determine the type** — read it off the change itself: a new user-visible feature is `feat`, a bug fix is `fix`. For the full 11-type set and each type's SemVer impact, read `references/conventional-commits-spec.md`. +3. **Determine the scope** — use the scope from plugin config where one is set, otherwise infer it from the files changed (`api`, `db`, `cli`, `config`). Scope is optional, but it identifies which part of the system moved and is worth setting. +4. **Write the description** — imperative mood, no trailing period: "add user authentication", "fix race condition in cache". Neither source spec sets a target below the 100-character header maximum, but convention favours roughly 50 characters so `git log --oneline` stays readable. +5. **Add a body when the change is non-trivial** — blank line first, wrapped at 100 characters. Explain *why*, not what: the diff already shows what changed, and the message's job is the context the diff cannot carry — motivation, root cause, tradeoffs. Follow the Why / Implementation Notes / Impact structure in `references/commit-template.md`. +6. **Add footers where they apply** — `Fixes: #123`, `Refs: #123`, `ADR: 0012`, `Co-authored-by: Name <email>`, `BREAKING CHANGE: description`. For the full trailer list, read `references/commit-template.md`. +7. **Signal a breaking change with `!` before the colon** — `feat!: drop Node 6` is visible in `git log --oneline`, where the `BREAKING CHANGE:` footer alone is machine-readable but hidden. Use both when the break needs describing. +8. **Validate, confirm, execute** — check header length, type, lowercase subject and trailing period against commitlint, show the message, and commit only once the caller has approved. Never add `--no-verify`. diff --git a/plugins/git/skills/git-commits/references/rewrite-history.md b/plugins/git/skills/git-commits/references/rewrite-history.md new file mode 100644 index 0000000..942f44f --- /dev/null +++ b/plugins/git/skills/git-commits/references/rewrite-history.md @@ -0,0 +1,37 @@ +--- +source_keys: + - org-commit-conventions + - context7-git-htmldocs +--- + +# Rewriting existing commits + +Every flow on this page rewrites history. None of them runs before the caller has explicitly approved it, and none is followed by a force-push to `main`/`master` — refuse that and explain why instead. + +## Amend the last commit + +1. Stage the new changes, or the changes that undo something. +2. Run `rtk git commit --amend`, adding `--no-edit` when the message stays as it is. +3. If the message should change, show the current one and prompt for the replacement. +4. The branch has now diverged from its remote. Amending is safe only on a branch nobody else has based work on; on `main`/`master`, refuse the force-push and explain, rather than warning and proceeding. + +## Fold a commit into an earlier one (autosquash — preferred) + +Prefer this whenever a commit is written to be folded, because git does the marking: + +1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit. +2. Get explicit approval — the rebase still rewrites history. +3. Run `rtk git rebase --autosquash HEAD~N`, or `-i --autosquash` to review the plan first. Git reorders the tagged commits against their targets automatically. + +## Squash by hand (interactive rebase) + +Use this when the commits were not tagged at commit time. **Interactive rebase has no undo once `rebase -i` starts — `git reflog` is the recovery path.** + +1. Identify the commits to squash — typically the last N on the current branch. +2. Get explicit approval. +3. Run `rtk git rebase -i HEAD~N`, marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them. +4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`. + +## When a rebase halts on a conflict + +Offer conflict resolution or `rtk git rebase --abort`. Do not resolve conflicts automatically without confirmation. diff --git a/plugins/git/skills/git-commits/references/sources.md b/plugins/git/skills/git-commits/references/sources.md index fdd4d36..960d8b3 100644 --- a/plugins/git/skills/git-commits/references/sources.md +++ b/plugins/git/skills/git-commits/references/sources.md @@ -7,34 +7,34 @@ source_keys: - context7-git-htmldocs --- -# Research Sources for git:commits Skill +# Research Sources for git-commits Skill -Sources extracted from the git plugin research phase. Only sources that directly informed this skill are listed; sibling skills (git:branches, git:history, git:remotes, etc.) have their own sources.md. +Sources extracted from the git plugin research phase. Only sources that directly informed this skill are listed; sibling skills (git-branches, git-history, git-remotes, etc.) have their own sources.md. ## conventional-commits-spec - **Description:** Conventional Commits Specification (v1.0.0) — message format, types, breaking changes, footer rules - **Research doc:** plugins/git/docs/research/docs/git/commits.md § "Conventional Commits Specification (v1.0.0)" -- **Contributing files:** SKILL.md, references/conventional-commits-spec.md +- **Contributing files:** SKILL.md, references/conventional-commits-spec.md, references/create-commit.md - **Status:** extracted ## commitlint-config-conventional - **Description:** commitlint config-conventional preset — validation constraints (max 100 chars header, no trailing periods, lowercase type, 11-type set enforcement) - **Research doc:** plugins/git/docs/research/docs/git/commits.md § "commitlint Constraints (`config-conventional`)" -- **Contributing files:** SKILL.md, references/conventional-commits-spec.md +- **Contributing files:** SKILL.md, references/conventional-commits-spec.md, references/create-commit.md - **Status:** extracted ## org-commit-conventions - **Description:** Organization commit message body template and git conventions (atomic commits, no `--no-verify`, no force-push main/master, `rtk git` wrapper) — content fully embedded in this skill; the org's `core/instructions/git.md` and `core/instructions/commits.md` are provenance only and are not a live dependency - **Research doc:** core/instructions/commits.md, core/instructions/git.md (org convention, not part of the plugin's research corpus) -- **Contributing files:** SKILL.md, references/commit-template.md +- **Contributing files:** SKILL.md, references/commit-template.md, references/create-commit.md, references/rewrite-history.md - **Status:** extracted ## context7-git-htmldocs -- **Description:** Official Git HTML documentation — `git commit --squash`/`--fixup` and `git rebase --autosquash` flag semantics -- **Research doc:** plugins/git/docs/research/docs/git/cli-reference.md -- **Contributing files:** SKILL.md +- **Description:** Official Git HTML documentation — `git commit --squash`/`--fixup`, `git rebase --autosquash`, and `git cherry-pick` range and abort semantics +- **Research doc:** plugins/git/docs/research/docs/git/cli-reference.md § "Committing", § "Rebasing", § "Cherry-picking" +- **Contributing files:** SKILL.md, references/rewrite-history.md, references/cherry-pick.md - **Status:** extracted -- 2.43.0 From 0fde892f2080ae55e1f19763e0f2d1e65c06f2ae Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 15/89] refactor(git-branches): retrofit to the ADR-0020 context contract Description 612 -> 273 chars, body 1124 -> 457 words. Branch patterns, operations, merging, comparison, and the orchestrator contract move to references/. Corrects rebase routing in four places: this skill sent rebase to git-history, which carries no rebase content and disclaims it. Rebase is git-commits'; cherry-pick and revert stay git-history's. Drops a Step 3 gate on a rebase flow this skill does not have. --- .../git/.apm/skills/git-branches/README.md | 15 ++- plugins/git/.apm/skills/git-branches/SKILL.md | 103 ++++++------------ .../references/branch-operations.md | 34 ++++++ .../references/branch-patterns.md | 31 ++++++ .../references/comparing-branches.md | 16 +++ .../skills/git-branches/references/merging.md | 29 +++++ .../references/orchestrator-contract.md | 16 +++ .../skills/git-branches/references/sources.md | 14 ++- plugins/git/skills/git-branches/README.md | 15 ++- plugins/git/skills/git-branches/SKILL.md | 103 ++++++------------ .../references/branch-operations.md | 34 ++++++ .../references/branch-patterns.md | 31 ++++++ .../references/comparing-branches.md | 16 +++ .../skills/git-branches/references/merging.md | 29 +++++ .../references/orchestrator-contract.md | 16 +++ .../skills/git-branches/references/sources.md | 14 ++- 16 files changed, 364 insertions(+), 152 deletions(-) create mode 100644 plugins/git/.apm/skills/git-branches/references/branch-operations.md create mode 100644 plugins/git/.apm/skills/git-branches/references/branch-patterns.md create mode 100644 plugins/git/.apm/skills/git-branches/references/comparing-branches.md create mode 100644 plugins/git/.apm/skills/git-branches/references/merging.md create mode 100644 plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md create mode 100644 plugins/git/skills/git-branches/references/branch-operations.md create mode 100644 plugins/git/skills/git-branches/references/branch-patterns.md create mode 100644 plugins/git/skills/git-branches/references/comparing-branches.md create mode 100644 plugins/git/skills/git-branches/references/merging.md create mode 100644 plugins/git/skills/git-branches/references/orchestrator-contract.md diff --git a/plugins/git/.apm/skills/git-branches/README.md b/plugins/git/.apm/skills/git-branches/README.md index c31a1bd..b3626dd 100644 --- a/plugins/git/.apm/skills/git-branches/README.md +++ b/plugins/git/.apm/skills/git-branches/README.md @@ -1,6 +1,6 @@ # git-branches -Manage the full lifecycle of git branches — create, switch, delete, rename, and track feature/hotfix/release branches under GitHub Flow or Gitflow. +Manage the full lifecycle of git branches — create, switch, delete, rename, track, merge, and compare feature/hotfix/release branches under GitHub Flow or Gitflow. ## What it does @@ -12,11 +12,22 @@ This skill handles branch operations within the git workflow suite. It creates b /git-branches ``` -Describe your branch task: create a feature/hotfix/release branch, switch, delete, rename, or track. The skill will determine the branching pattern (GitHub Flow or Gitflow) from config or repo state and handle safety checks for destructive operations. +Describe your branch task: create a feature/hotfix/release branch, switch, delete, rename, track, merge, or compare two branches. The skill will determine the branching pattern (GitHub Flow or Gitflow) from config or repo state and handle safety checks for destructive operations. ## Files | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/branch-patterns.md` | Loaded when a branch's base, name prefix, or merge rule depends on GitHub Flow vs. Gitflow | +| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list action, or resolving `get-intent` | +| `references/merging.md` | Loaded when merging one branch into another or resolving merge conflicts | +| `references/comparing-branches.md` | Loaded when comparing two branches or finding where they diverged | +| `references/orchestrator-contract.md` | Loaded when `git-orchestrate` or another calling agent supplies a structured request rather than prose | | `references/sources.md` | Research sources backing the branching/gitflow guidance | + +## Composition + +`git-orchestrate` calls this skill for the branch step of a multi-step workflow and parses its +structured result. Cherry-pick and revert are `git-history`'s; commit authoring and rebase are +`git-commits`'; branch operations against a Gitea-hosted remote are `gitea-branches`'. diff --git a/plugins/git/.apm/skills/git-branches/SKILL.md b/plugins/git/.apm/skills/git-branches/SKILL.md index cef3f0c..7a2e715 100644 --- a/plugins/git/.apm/skills/git-branches/SKILL.md +++ b/plugins/git/.apm/skills/git-branches/SKILL.md @@ -2,13 +2,11 @@ name: git-branches description: > - Use when managing the full lifecycle of git branches: create feature/hotfix/release branches - (gitflow, GitHub Flow, or custom patterns from config), switch, delete, rename, and track branches, - or retrieve branch intent metadata. Handles branch protection safety checks and returns structured - results for agent composition. Use even if the user doesn't explicitly mention branch names — they - may be asking about "fixing something" or "shipping a feature", which implicitly requires branch - management. Do not use when the user needs only commit operations (use git-commits) or history - inspection (use git-history). + Use when creating, switching, deleting, renaming, tracking, merging, or comparing + local git branches under GitHub Flow or Gitflow. + Not writing or rewriting commits -> `git-commits`. + Not history inspection -> `git-history`. + Not a Gitea remote's branches -> `gitea-branches`. metadata: category: git @@ -21,92 +19,57 @@ metadata: ## Gotchas -- **Branches are cheap; deletion is cheap but risky.** Deleting one requires checking if commits on it are reachable elsewhere; always confirm before deleting, as it may lose unmerged work. -- **Uncommitted changes can block branch switches.** `git switch` aborts if local modifications conflict with the target branch. Offer to stash changes before switching when this happens, don't force a checkout. -- **Tracking relationships matter for coordination.** Agents pushing on behalf of users should always set tracking (`-u origin <branch>`) so later pushes/pulls know the target. Without it, commands fail or target the wrong remote branch. -- **Gitflow vs. GitHub Flow are not compatible.** Gitflow requires `develop` and `release/*` branches with `--no-ff` merges; GitHub Flow uses only `main` and feature branches with fast-forward. Read the repo's config or ask the orchestrator which pattern to use — don't guess. -- **Naming collisions with tags.** A branch and tag can have the same name. Prefer `git switch` over `git checkout` for branch operations — verify which ref you're targeting with `git branch --list <name>` / `git tag --list <name>` if the name could be ambiguous, and disambiguate explicitly with `refs/heads/<name>` (branch) or `refs/tags/<name>` (tag) where a command accepts either. -- **Never force-push `main` or `master`.** This is a hard refusal, not a confirmation gate — it applies even if the caller passes `confirm: true`. Deleting or renaming `main`/`master` in a way that would require a force-push to reconcile the remote (e.g. force-deleting and recreating it, or renaming it out from under in-flight work) must be rejected outright; explain why and suggest a non-destructive alternative (e.g. a new branch) instead of proceeding. +- **Uncommitted changes abort a switch.** `git switch` refuses rather than clobbering conflicting local edits. Offer to stash and retry — forcing the checkout past it is how work disappears. +- **A branch and a tag can carry the same name.** Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. +- **`main` and `master` are a refusal, not a gate.** Force-pushing, force-deleting, or renaming them is rejected even when the caller passes `confirm: true` — no flag makes the remote's history recoverable. Offer a new branch instead. -## Branch Patterns +## Step 1 — Determine the branching pattern -Default to **GitHub Flow** (simpler, modern, CI/CD-friendly). Fall back to **Gitflow** only if the repo's config specifies it or the branch structure shows it in use (presence of `develop` or release branches). +Read `branching_pattern` from the git plugin config (`.claude/plugins/git/config.json`; the plugin root's `config.example.json` shows the shape). Default: `github-flow`. With no config, infer Gitflow from the presence of a `develop` or `release/*` branch, and GitHub Flow otherwise. -**GitHub Flow:** -- Base: `main` -- Feature branches: `feature/<feature-name>` or `fix/<bug-name>` -- Merge: fast-forward when possible (preserves linear history) -- Delete after merge +The two patterns are not mixable, and the wrong merge rule silently damages history. If the action touches a base branch, a name prefix, or a merge rule, read `references/branch-patterns.md`. -**Gitflow:** -- Base: `main` (production) + `develop` (integration) -- Feature branches: `feature/<feature-name>` (from `develop`) -- Release branches: `release/X.Y.Z` (from `develop`, merged to `main` + `develop`) -- Hotfix branches: `hotfix/X.Y.Z` (from `main`, merged to `main` + `develop`) -- Merge: always use `--no-ff` to preserve branch structure +## Step 2 — Dispatch on the action -## Workflow +| Action | Reference | +|---|---| +| create, switch, delete, rename, track, list, get-intent | `references/branch-operations.md` | +| merge a branch, resolve merge conflicts | `references/merging.md` | +| compare two branches, find their divergence | `references/comparing-branches.md` | -- [ ] **Determine pattern:** Check git plugin config (`.claude/plugins/git/config.json`, if present — see `config.example.json` in the plugin root for the expected shape) for `branching_pattern` (default: `github-flow`). If not set, inspect repo for `develop` branch or `release/*` branches; if present, assume Gitflow. -- [ ] **Create branch:** Use `git switch -c <branch> <base>`. Base defaults to config's `base_branch` (usually `main` or `develop`). Include intent metadata in branch name or return as structured result (e.g., `{ "branch": "feature/x", "intent": "implement feature X" }`). -- [ ] **Track remote:** If pushing, always use `git push -u origin <branch>` to establish tracking. -- [ ] **Safety checks before destructive ops:** Before delete/force-push/rebase with history loss, check: (1) Is this branch tracking a remote? Warn if yes. (2) Are there unpushed commits? Warn if yes. (3) Does the orchestrator call include `confirm: true`? Fail if not. For humans, prompt interactively. -- [ ] **Return structured results:** Always return branch operations as JSON or structured text: `{ "action": "create", "branch": "feature/x", "base": "main", "tracking": "origin/feature/x", "intent": "implement feature X" }`. Agents need to parse this for subsequent operations. -- [ ] **Retrieve intent (`get-intent`):** Git has no native field for free-text branch metadata — this skill doesn't persist it. On `create`, the `intent` value is only ever returned in the structured result; the caller (orchestrator or agent) is responsible for storing it if it needs to be looked up later. On `get-intent`, either parse it back out of the branch name convention (`feature/<intent-slug>`) or return `{ "intent": null }` if the caller never persisted the original create-time value — don't fabricate an intent. +Load only the file the action needs. A destructive action still passes Step 3 first. -### Command mapping for each action +## Step 3 — Gate destructive operations -- **delete:** `git branch -d <branch>` refuses if the branch has unmerged commits — prefer this by default. `git branch -D <branch>` forces deletion and discards unmerged work; only use it after the safety checks above pass and `confirm: true` is set. For a remote branch: `git push origin --delete <branch>`. -- **rename:** `git branch -m <old> <new>`. -- **list:** `git branch` (local only), `git branch -a` (all local + remote-tracking), `git branch -r` (remote-tracking only), `git branch --merged`/`--no-merged` (filter by merge status into current branch). -- **get-intent:** No git command — see Workflow step "Retrieve intent" for how this is resolved. -- **track (existing branch):** `git branch --set-upstream-to=origin/<branch>` sets tracking without a push; `git branch -vv` shows tracking state for all local branches. -- **switch (existing branch):** `git switch <branch>` — switches to an existing local branch (aborts on conflicting local changes, see Gotchas). `git switch -` switches back to the previously checked-out branch. +Before any delete or force-delete that loses history: -## Merging +- [ ] Does the branch track a remote? Warn if so. +- [ ] Are there unpushed commits on it? Warn if so. +- [ ] Did the caller pass `confirm: true`? Fail if not — for a human caller, prompt interactively instead of failing. -Scope: fast-forward/merge-commit mechanics and conflict resolution only. Rebase, cherry-pick, and revert belong to `git-history`. +These gates are passable. The `main`/`master` refusal in Gotchas is not. -- **Fast-forward:** `git merge <branch>` — advances the pointer with no merge commit if the target hasn't diverged. -- **True merge:** `git merge --no-ff <branch>` — forces a merge commit even when fast-forward is possible; required by Gitflow on all supporting-branch merges. -- **Squash merge:** `git merge --squash <branch>` stages the combined diff without committing; follow with a manual `git commit`. -- **Octopus merge:** `git merge branch-a branch-b branch-c` merges more than two branches at once; fails outright on any conflict, so use sequential two-way merges if conflicts are expected. +## Step 4 — Set tracking -**Conflict resolution:** when Git can't auto-merge, it inserts conflict markers and stops. Run `git status` to find conflicted files, edit them to resolve the markers, then `git add <file>` and `git merge --continue`. `git merge --abort` reverts to the pre-merge state. `git mergetool` opens the configured merge tool; `git diff --diff-filter=U` shows only conflicted files. +When pushing a branch for the first time, always `git push -u origin <branch>`. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. -## Comparing Branches +## Step 5 — Return a structured result -- `git log main..feature` — commits in `feature` not in `main`. -- `git log feature..main` — commits in `main` not in `feature` (reverse direction). -- `git log --left-right main...feature` — both diverging sets (symmetric diff). -- `git diff main...feature` — diff from the common ancestor to `feature`'s tip. -- `git merge-base main feature` — print the common ancestor commit. +Return every operation in this shape rather than prose, including failures — a calling agent chains its next operation on the result and cannot parse a sentence. -## Integration with Orchestrator - -When invoked by `git-orchestrate`, accept requests in the form: -```json -{ - "action": "create|switch|delete|rename|track|list|get-intent", - "branch": "<branch-name>", - "base": "<base-branch (optional, defaults to config)>", - "intent": "<human-readable intent (optional)>", - "confirm": "<true for destructive ops, omit for read ops>" -} -``` - -Return results as: ```json { "success": true, - "action": "create|switch|...", + "action": "create|switch|delete|rename|track|list|get-intent", "branch": "<name>", "message": "descriptive message", "intent": "<intent if tracked>", - "tracking": "origin/<branch (if set)>", + "tracking": "origin/<branch, if set>", "error": "<error message if success=false>", "suggestion": "<recovery suggestion if applicable>" } ``` -If error is due to uncommitted changes, include `{ "suggestion": "stash changes and retry" }` so the orchestrator can offer automatic recovery. +When the failure is uncommitted local changes, set `"suggestion": "stash changes and retry"` so the caller can offer recovery rather than surfacing a dead end. + +When a calling agent supplies a structured request rather than prose, read `references/orchestrator-contract.md` for the request schema. diff --git a/plugins/git/.apm/skills/git-branches/references/branch-operations.md b/plugins/git/.apm/skills/git-branches/references/branch-operations.md new file mode 100644 index 0000000..8c428cc --- /dev/null +++ b/plugins/git/.apm/skills/git-branches/references/branch-operations.md @@ -0,0 +1,34 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Per-action command mapping + +One command per action. Where two forms exist, the first is the default and the second the escape +hatch. + +- **create** — `git switch -c <branch> <base>`. Base comes from the config's `base_branch` + (`main` under GitHub Flow, usually `develop` under Gitflow). +- **switch** — `git switch <branch>` moves to an existing local branch; it aborts rather than + clobbering conflicting local changes. `git switch -` returns to the previous branch. +- **delete (local)** — `git branch -d <branch>` refuses when the branch holds unmerged commits, + which is why it is the default. `git branch -D <branch>` forces the deletion and discards that + work — only after the destructive-operation gates pass and `confirm: true` is set. +- **delete (remote)** — `git push origin --delete <branch>`. +- **rename** — `git branch -m <old> <new>`. +- **list** — `git branch` (local), `-a` (local plus remote-tracking), `-r` (remote-tracking only), + `--merged` / `--no-merged` (filter by merge status into the current branch). +- **track** — `git branch --set-upstream-to=origin/<branch>` sets an upstream without pushing. + `git branch -vv` shows the tracking state of every local branch. + +## get-intent + +Git has no native field for free-text branch metadata, and this skill does not persist any. On +`create`, the `intent` value is only returned in the structured result — the caller decides +whether to store it. + +On `get-intent`, either parse the intent back out of the branch-name convention +(`feature/<intent-slug>`) or return `{ "intent": null }` when the caller never persisted the +create-time value. Never fabricate an intent: a downstream commit message built on a guessed +intent is worse than one built on none. diff --git a/plugins/git/.apm/skills/git-branches/references/branch-patterns.md b/plugins/git/.apm/skills/git-branches/references/branch-patterns.md new file mode 100644 index 0000000..84e6680 --- /dev/null +++ b/plugins/git/.apm/skills/git-branches/references/branch-patterns.md @@ -0,0 +1,31 @@ +--- +source_keys: + - nvie-gitflow-post + - atlassian-gitflow-tutorial + - gitflow-cheatsheet +--- + +# Branch patterns + +Which pattern is in play decides the base branch, the branch name prefix, and whether merges are +allowed to fast-forward. Default to GitHub Flow — simpler, and what CI/CD-oriented repos expect. +Fall back to Gitflow only when the config says so or the repo already carries `develop` or +`release/*` branches. + +## GitHub Flow + +- Base: `main` +- Feature branches: `feature/<feature-name>` or `fix/<bug-name>` +- Merge: fast-forward where possible, to keep history linear +- Delete the branch after merge + +## Gitflow + +- Base: `main` (production) plus `develop` (integration) +- Feature branches: `feature/<feature-name>`, cut from `develop` +- Release branches: `release/X.Y.Z`, cut from `develop`, merged to both `main` and `develop` +- Hotfix branches: `hotfix/X.Y.Z`, cut from `main`, merged to both `main` and `develop` +- Merge: always `--no-ff`, so the branch structure survives in the history + +The two are not mixable. A `--no-ff` merge into a GitHub Flow repo leaves merge commits nobody +expects; a fast-forward merge of a Gitflow release branch erases the release boundary. diff --git a/plugins/git/.apm/skills/git-branches/references/comparing-branches.md b/plugins/git/.apm/skills/git-branches/references/comparing-branches.md new file mode 100644 index 0000000..24c3214 --- /dev/null +++ b/plugins/git/.apm/skills/git-branches/references/comparing-branches.md @@ -0,0 +1,16 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Comparing two branches + +The two-dot and three-dot forms mean different things and are easy to swap by accident — check the +direction before reporting a result. + +- `git log main..feature` — commits on `feature` that are not on `main`. +- `git log feature..main` — the reverse direction: commits on `main` not on `feature`. +- `git log --left-right main...feature` — both diverging sets at once (symmetric difference). +- `git diff main...feature` — the diff from the common ancestor to `feature`'s tip, which is what + a reviewer sees, rather than the diff between the two tips. +- `git merge-base main feature` — print the common ancestor commit. diff --git a/plugins/git/.apm/skills/git-branches/references/merging.md b/plugins/git/.apm/skills/git-branches/references/merging.md new file mode 100644 index 0000000..0c8f245 --- /dev/null +++ b/plugins/git/.apm/skills/git-branches/references/merging.md @@ -0,0 +1,29 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Merging one branch into another + +Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase belongs to +`git-commits`; cherry-pick and revert to `git-history`. + +- **Fast-forward** — `git merge <branch>` advances the pointer with no merge commit when the + target has not diverged. +- **True merge** — `git merge --no-ff <branch>` forces a merge commit even when a fast-forward is + possible. Gitflow requires it on every supporting-branch merge. +- **Squash merge** — `git merge --squash <branch>` stages the combined diff without committing. + Follow it with a `git commit`. +- **Octopus merge** — `git merge branch-a branch-b branch-c` merges more than two branches at + once, but fails outright on any conflict. Use sequential two-way merges when conflicts are + likely. + +## Conflict resolution + +When Git cannot auto-merge it writes conflict markers and stops mid-merge. Run `git status` to +list the conflicted files, edit each to resolve its markers, then `git add <file>` and +`git merge --continue`. + +- `git merge --abort` restores the pre-merge state. +- `git mergetool` opens the configured merge tool. +- `git diff --diff-filter=U` shows only the still-conflicted files. diff --git a/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md b/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md new file mode 100644 index 0000000..7fceb33 --- /dev/null +++ b/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md @@ -0,0 +1,16 @@ +# Orchestrator request contract + +`git-orchestrate` and other calling agents send this shape. The result shape they parse back is in +`SKILL.md` Step 5, because every run emits one. + +Request: + +```json +{ + "action": "create|switch|delete|rename|track|list|get-intent", + "branch": "<branch-name>", + "base": "<base branch, optional, defaults to config>", + "intent": "<human-readable intent, optional>", + "confirm": "<true for destructive ops, omit for read ops>" +} +``` diff --git a/plugins/git/.apm/skills/git-branches/references/sources.md b/plugins/git/.apm/skills/git-branches/references/sources.md index d65c617..506756c 100644 --- a/plugins/git/.apm/skills/git-branches/references/sources.md +++ b/plugins/git/.apm/skills/git-branches/references/sources.md @@ -12,7 +12,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — Gitflow vs. GitHub Flow structure and defaults) +- SKILL.md (Gitflow vs. GitHub Flow inference and the not-mixable rule) +- references/branch-patterns.md (Gitflow vs. GitHub Flow structure, defaults, and why the two are not mixable) ## atlassian-gitflow-tutorial @@ -23,7 +24,9 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — Gitflow branch types, base/merge targets, `--no-ff` requirement) +- SKILL.md (Gitflow vs. GitHub Flow inference and the not-mixable rule) +- references/branch-patterns.md (Gitflow branch types, base/merge targets, `--no-ff` requirement) +- references/merging.md (`--no-ff` requirement on Gitflow supporting-branch merges) ## gitflow-cheatsheet @@ -34,7 +37,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — feature/release/hotfix naming conventions) +- references/branch-patterns.md (feature/release/hotfix naming conventions) ## context7-git-htmldocs @@ -45,4 +48,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/branching-merging.md (whole-document reference) **Contributing files:** -- SKILL.md (Command mapping, Merging, Comparing Branches — `git switch`/`git branch`/`git merge`/`git log`/`git diff`/`git merge-base` command vocabulary and flags) +- SKILL.md (Gotchas — `git switch` abort-on-conflict behaviour, branch/tag name ambiguity) +- references/branch-operations.md (`git switch`/`git branch` command vocabulary and flags) +- references/merging.md (`git merge` strategies and conflict-resolution commands) +- references/comparing-branches.md (`git log`/`git diff`/`git merge-base` range syntax) diff --git a/plugins/git/skills/git-branches/README.md b/plugins/git/skills/git-branches/README.md index c31a1bd..b3626dd 100644 --- a/plugins/git/skills/git-branches/README.md +++ b/plugins/git/skills/git-branches/README.md @@ -1,6 +1,6 @@ # git-branches -Manage the full lifecycle of git branches — create, switch, delete, rename, and track feature/hotfix/release branches under GitHub Flow or Gitflow. +Manage the full lifecycle of git branches — create, switch, delete, rename, track, merge, and compare feature/hotfix/release branches under GitHub Flow or Gitflow. ## What it does @@ -12,11 +12,22 @@ This skill handles branch operations within the git workflow suite. It creates b /git-branches ``` -Describe your branch task: create a feature/hotfix/release branch, switch, delete, rename, or track. The skill will determine the branching pattern (GitHub Flow or Gitflow) from config or repo state and handle safety checks for destructive operations. +Describe your branch task: create a feature/hotfix/release branch, switch, delete, rename, track, merge, or compare two branches. The skill will determine the branching pattern (GitHub Flow or Gitflow) from config or repo state and handle safety checks for destructive operations. ## Files | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/branch-patterns.md` | Loaded when a branch's base, name prefix, or merge rule depends on GitHub Flow vs. Gitflow | +| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list action, or resolving `get-intent` | +| `references/merging.md` | Loaded when merging one branch into another or resolving merge conflicts | +| `references/comparing-branches.md` | Loaded when comparing two branches or finding where they diverged | +| `references/orchestrator-contract.md` | Loaded when `git-orchestrate` or another calling agent supplies a structured request rather than prose | | `references/sources.md` | Research sources backing the branching/gitflow guidance | + +## Composition + +`git-orchestrate` calls this skill for the branch step of a multi-step workflow and parses its +structured result. Cherry-pick and revert are `git-history`'s; commit authoring and rebase are +`git-commits`'; branch operations against a Gitea-hosted remote are `gitea-branches`'. diff --git a/plugins/git/skills/git-branches/SKILL.md b/plugins/git/skills/git-branches/SKILL.md index cef3f0c..7a2e715 100644 --- a/plugins/git/skills/git-branches/SKILL.md +++ b/plugins/git/skills/git-branches/SKILL.md @@ -2,13 +2,11 @@ name: git-branches description: > - Use when managing the full lifecycle of git branches: create feature/hotfix/release branches - (gitflow, GitHub Flow, or custom patterns from config), switch, delete, rename, and track branches, - or retrieve branch intent metadata. Handles branch protection safety checks and returns structured - results for agent composition. Use even if the user doesn't explicitly mention branch names — they - may be asking about "fixing something" or "shipping a feature", which implicitly requires branch - management. Do not use when the user needs only commit operations (use git-commits) or history - inspection (use git-history). + Use when creating, switching, deleting, renaming, tracking, merging, or comparing + local git branches under GitHub Flow or Gitflow. + Not writing or rewriting commits -> `git-commits`. + Not history inspection -> `git-history`. + Not a Gitea remote's branches -> `gitea-branches`. metadata: category: git @@ -21,92 +19,57 @@ metadata: ## Gotchas -- **Branches are cheap; deletion is cheap but risky.** Deleting one requires checking if commits on it are reachable elsewhere; always confirm before deleting, as it may lose unmerged work. -- **Uncommitted changes can block branch switches.** `git switch` aborts if local modifications conflict with the target branch. Offer to stash changes before switching when this happens, don't force a checkout. -- **Tracking relationships matter for coordination.** Agents pushing on behalf of users should always set tracking (`-u origin <branch>`) so later pushes/pulls know the target. Without it, commands fail or target the wrong remote branch. -- **Gitflow vs. GitHub Flow are not compatible.** Gitflow requires `develop` and `release/*` branches with `--no-ff` merges; GitHub Flow uses only `main` and feature branches with fast-forward. Read the repo's config or ask the orchestrator which pattern to use — don't guess. -- **Naming collisions with tags.** A branch and tag can have the same name. Prefer `git switch` over `git checkout` for branch operations — verify which ref you're targeting with `git branch --list <name>` / `git tag --list <name>` if the name could be ambiguous, and disambiguate explicitly with `refs/heads/<name>` (branch) or `refs/tags/<name>` (tag) where a command accepts either. -- **Never force-push `main` or `master`.** This is a hard refusal, not a confirmation gate — it applies even if the caller passes `confirm: true`. Deleting or renaming `main`/`master` in a way that would require a force-push to reconcile the remote (e.g. force-deleting and recreating it, or renaming it out from under in-flight work) must be rejected outright; explain why and suggest a non-destructive alternative (e.g. a new branch) instead of proceeding. +- **Uncommitted changes abort a switch.** `git switch` refuses rather than clobbering conflicting local edits. Offer to stash and retry — forcing the checkout past it is how work disappears. +- **A branch and a tag can carry the same name.** Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. +- **`main` and `master` are a refusal, not a gate.** Force-pushing, force-deleting, or renaming them is rejected even when the caller passes `confirm: true` — no flag makes the remote's history recoverable. Offer a new branch instead. -## Branch Patterns +## Step 1 — Determine the branching pattern -Default to **GitHub Flow** (simpler, modern, CI/CD-friendly). Fall back to **Gitflow** only if the repo's config specifies it or the branch structure shows it in use (presence of `develop` or release branches). +Read `branching_pattern` from the git plugin config (`.claude/plugins/git/config.json`; the plugin root's `config.example.json` shows the shape). Default: `github-flow`. With no config, infer Gitflow from the presence of a `develop` or `release/*` branch, and GitHub Flow otherwise. -**GitHub Flow:** -- Base: `main` -- Feature branches: `feature/<feature-name>` or `fix/<bug-name>` -- Merge: fast-forward when possible (preserves linear history) -- Delete after merge +The two patterns are not mixable, and the wrong merge rule silently damages history. If the action touches a base branch, a name prefix, or a merge rule, read `references/branch-patterns.md`. -**Gitflow:** -- Base: `main` (production) + `develop` (integration) -- Feature branches: `feature/<feature-name>` (from `develop`) -- Release branches: `release/X.Y.Z` (from `develop`, merged to `main` + `develop`) -- Hotfix branches: `hotfix/X.Y.Z` (from `main`, merged to `main` + `develop`) -- Merge: always use `--no-ff` to preserve branch structure +## Step 2 — Dispatch on the action -## Workflow +| Action | Reference | +|---|---| +| create, switch, delete, rename, track, list, get-intent | `references/branch-operations.md` | +| merge a branch, resolve merge conflicts | `references/merging.md` | +| compare two branches, find their divergence | `references/comparing-branches.md` | -- [ ] **Determine pattern:** Check git plugin config (`.claude/plugins/git/config.json`, if present — see `config.example.json` in the plugin root for the expected shape) for `branching_pattern` (default: `github-flow`). If not set, inspect repo for `develop` branch or `release/*` branches; if present, assume Gitflow. -- [ ] **Create branch:** Use `git switch -c <branch> <base>`. Base defaults to config's `base_branch` (usually `main` or `develop`). Include intent metadata in branch name or return as structured result (e.g., `{ "branch": "feature/x", "intent": "implement feature X" }`). -- [ ] **Track remote:** If pushing, always use `git push -u origin <branch>` to establish tracking. -- [ ] **Safety checks before destructive ops:** Before delete/force-push/rebase with history loss, check: (1) Is this branch tracking a remote? Warn if yes. (2) Are there unpushed commits? Warn if yes. (3) Does the orchestrator call include `confirm: true`? Fail if not. For humans, prompt interactively. -- [ ] **Return structured results:** Always return branch operations as JSON or structured text: `{ "action": "create", "branch": "feature/x", "base": "main", "tracking": "origin/feature/x", "intent": "implement feature X" }`. Agents need to parse this for subsequent operations. -- [ ] **Retrieve intent (`get-intent`):** Git has no native field for free-text branch metadata — this skill doesn't persist it. On `create`, the `intent` value is only ever returned in the structured result; the caller (orchestrator or agent) is responsible for storing it if it needs to be looked up later. On `get-intent`, either parse it back out of the branch name convention (`feature/<intent-slug>`) or return `{ "intent": null }` if the caller never persisted the original create-time value — don't fabricate an intent. +Load only the file the action needs. A destructive action still passes Step 3 first. -### Command mapping for each action +## Step 3 — Gate destructive operations -- **delete:** `git branch -d <branch>` refuses if the branch has unmerged commits — prefer this by default. `git branch -D <branch>` forces deletion and discards unmerged work; only use it after the safety checks above pass and `confirm: true` is set. For a remote branch: `git push origin --delete <branch>`. -- **rename:** `git branch -m <old> <new>`. -- **list:** `git branch` (local only), `git branch -a` (all local + remote-tracking), `git branch -r` (remote-tracking only), `git branch --merged`/`--no-merged` (filter by merge status into current branch). -- **get-intent:** No git command — see Workflow step "Retrieve intent" for how this is resolved. -- **track (existing branch):** `git branch --set-upstream-to=origin/<branch>` sets tracking without a push; `git branch -vv` shows tracking state for all local branches. -- **switch (existing branch):** `git switch <branch>` — switches to an existing local branch (aborts on conflicting local changes, see Gotchas). `git switch -` switches back to the previously checked-out branch. +Before any delete or force-delete that loses history: -## Merging +- [ ] Does the branch track a remote? Warn if so. +- [ ] Are there unpushed commits on it? Warn if so. +- [ ] Did the caller pass `confirm: true`? Fail if not — for a human caller, prompt interactively instead of failing. -Scope: fast-forward/merge-commit mechanics and conflict resolution only. Rebase, cherry-pick, and revert belong to `git-history`. +These gates are passable. The `main`/`master` refusal in Gotchas is not. -- **Fast-forward:** `git merge <branch>` — advances the pointer with no merge commit if the target hasn't diverged. -- **True merge:** `git merge --no-ff <branch>` — forces a merge commit even when fast-forward is possible; required by Gitflow on all supporting-branch merges. -- **Squash merge:** `git merge --squash <branch>` stages the combined diff without committing; follow with a manual `git commit`. -- **Octopus merge:** `git merge branch-a branch-b branch-c` merges more than two branches at once; fails outright on any conflict, so use sequential two-way merges if conflicts are expected. +## Step 4 — Set tracking -**Conflict resolution:** when Git can't auto-merge, it inserts conflict markers and stops. Run `git status` to find conflicted files, edit them to resolve the markers, then `git add <file>` and `git merge --continue`. `git merge --abort` reverts to the pre-merge state. `git mergetool` opens the configured merge tool; `git diff --diff-filter=U` shows only conflicted files. +When pushing a branch for the first time, always `git push -u origin <branch>`. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. -## Comparing Branches +## Step 5 — Return a structured result -- `git log main..feature` — commits in `feature` not in `main`. -- `git log feature..main` — commits in `main` not in `feature` (reverse direction). -- `git log --left-right main...feature` — both diverging sets (symmetric diff). -- `git diff main...feature` — diff from the common ancestor to `feature`'s tip. -- `git merge-base main feature` — print the common ancestor commit. +Return every operation in this shape rather than prose, including failures — a calling agent chains its next operation on the result and cannot parse a sentence. -## Integration with Orchestrator - -When invoked by `git-orchestrate`, accept requests in the form: -```json -{ - "action": "create|switch|delete|rename|track|list|get-intent", - "branch": "<branch-name>", - "base": "<base-branch (optional, defaults to config)>", - "intent": "<human-readable intent (optional)>", - "confirm": "<true for destructive ops, omit for read ops>" -} -``` - -Return results as: ```json { "success": true, - "action": "create|switch|...", + "action": "create|switch|delete|rename|track|list|get-intent", "branch": "<name>", "message": "descriptive message", "intent": "<intent if tracked>", - "tracking": "origin/<branch (if set)>", + "tracking": "origin/<branch, if set>", "error": "<error message if success=false>", "suggestion": "<recovery suggestion if applicable>" } ``` -If error is due to uncommitted changes, include `{ "suggestion": "stash changes and retry" }` so the orchestrator can offer automatic recovery. +When the failure is uncommitted local changes, set `"suggestion": "stash changes and retry"` so the caller can offer recovery rather than surfacing a dead end. + +When a calling agent supplies a structured request rather than prose, read `references/orchestrator-contract.md` for the request schema. diff --git a/plugins/git/skills/git-branches/references/branch-operations.md b/plugins/git/skills/git-branches/references/branch-operations.md new file mode 100644 index 0000000..8c428cc --- /dev/null +++ b/plugins/git/skills/git-branches/references/branch-operations.md @@ -0,0 +1,34 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Per-action command mapping + +One command per action. Where two forms exist, the first is the default and the second the escape +hatch. + +- **create** — `git switch -c <branch> <base>`. Base comes from the config's `base_branch` + (`main` under GitHub Flow, usually `develop` under Gitflow). +- **switch** — `git switch <branch>` moves to an existing local branch; it aborts rather than + clobbering conflicting local changes. `git switch -` returns to the previous branch. +- **delete (local)** — `git branch -d <branch>` refuses when the branch holds unmerged commits, + which is why it is the default. `git branch -D <branch>` forces the deletion and discards that + work — only after the destructive-operation gates pass and `confirm: true` is set. +- **delete (remote)** — `git push origin --delete <branch>`. +- **rename** — `git branch -m <old> <new>`. +- **list** — `git branch` (local), `-a` (local plus remote-tracking), `-r` (remote-tracking only), + `--merged` / `--no-merged` (filter by merge status into the current branch). +- **track** — `git branch --set-upstream-to=origin/<branch>` sets an upstream without pushing. + `git branch -vv` shows the tracking state of every local branch. + +## get-intent + +Git has no native field for free-text branch metadata, and this skill does not persist any. On +`create`, the `intent` value is only returned in the structured result — the caller decides +whether to store it. + +On `get-intent`, either parse the intent back out of the branch-name convention +(`feature/<intent-slug>`) or return `{ "intent": null }` when the caller never persisted the +create-time value. Never fabricate an intent: a downstream commit message built on a guessed +intent is worse than one built on none. diff --git a/plugins/git/skills/git-branches/references/branch-patterns.md b/plugins/git/skills/git-branches/references/branch-patterns.md new file mode 100644 index 0000000..84e6680 --- /dev/null +++ b/plugins/git/skills/git-branches/references/branch-patterns.md @@ -0,0 +1,31 @@ +--- +source_keys: + - nvie-gitflow-post + - atlassian-gitflow-tutorial + - gitflow-cheatsheet +--- + +# Branch patterns + +Which pattern is in play decides the base branch, the branch name prefix, and whether merges are +allowed to fast-forward. Default to GitHub Flow — simpler, and what CI/CD-oriented repos expect. +Fall back to Gitflow only when the config says so or the repo already carries `develop` or +`release/*` branches. + +## GitHub Flow + +- Base: `main` +- Feature branches: `feature/<feature-name>` or `fix/<bug-name>` +- Merge: fast-forward where possible, to keep history linear +- Delete the branch after merge + +## Gitflow + +- Base: `main` (production) plus `develop` (integration) +- Feature branches: `feature/<feature-name>`, cut from `develop` +- Release branches: `release/X.Y.Z`, cut from `develop`, merged to both `main` and `develop` +- Hotfix branches: `hotfix/X.Y.Z`, cut from `main`, merged to both `main` and `develop` +- Merge: always `--no-ff`, so the branch structure survives in the history + +The two are not mixable. A `--no-ff` merge into a GitHub Flow repo leaves merge commits nobody +expects; a fast-forward merge of a Gitflow release branch erases the release boundary. diff --git a/plugins/git/skills/git-branches/references/comparing-branches.md b/plugins/git/skills/git-branches/references/comparing-branches.md new file mode 100644 index 0000000..24c3214 --- /dev/null +++ b/plugins/git/skills/git-branches/references/comparing-branches.md @@ -0,0 +1,16 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Comparing two branches + +The two-dot and three-dot forms mean different things and are easy to swap by accident — check the +direction before reporting a result. + +- `git log main..feature` — commits on `feature` that are not on `main`. +- `git log feature..main` — the reverse direction: commits on `main` not on `feature`. +- `git log --left-right main...feature` — both diverging sets at once (symmetric difference). +- `git diff main...feature` — the diff from the common ancestor to `feature`'s tip, which is what + a reviewer sees, rather than the diff between the two tips. +- `git merge-base main feature` — print the common ancestor commit. diff --git a/plugins/git/skills/git-branches/references/merging.md b/plugins/git/skills/git-branches/references/merging.md new file mode 100644 index 0000000..0c8f245 --- /dev/null +++ b/plugins/git/skills/git-branches/references/merging.md @@ -0,0 +1,29 @@ +--- +source_keys: + - context7-git-htmldocs +--- + +# Merging one branch into another + +Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase belongs to +`git-commits`; cherry-pick and revert to `git-history`. + +- **Fast-forward** — `git merge <branch>` advances the pointer with no merge commit when the + target has not diverged. +- **True merge** — `git merge --no-ff <branch>` forces a merge commit even when a fast-forward is + possible. Gitflow requires it on every supporting-branch merge. +- **Squash merge** — `git merge --squash <branch>` stages the combined diff without committing. + Follow it with a `git commit`. +- **Octopus merge** — `git merge branch-a branch-b branch-c` merges more than two branches at + once, but fails outright on any conflict. Use sequential two-way merges when conflicts are + likely. + +## Conflict resolution + +When Git cannot auto-merge it writes conflict markers and stops mid-merge. Run `git status` to +list the conflicted files, edit each to resolve its markers, then `git add <file>` and +`git merge --continue`. + +- `git merge --abort` restores the pre-merge state. +- `git mergetool` opens the configured merge tool. +- `git diff --diff-filter=U` shows only the still-conflicted files. diff --git a/plugins/git/skills/git-branches/references/orchestrator-contract.md b/plugins/git/skills/git-branches/references/orchestrator-contract.md new file mode 100644 index 0000000..7fceb33 --- /dev/null +++ b/plugins/git/skills/git-branches/references/orchestrator-contract.md @@ -0,0 +1,16 @@ +# Orchestrator request contract + +`git-orchestrate` and other calling agents send this shape. The result shape they parse back is in +`SKILL.md` Step 5, because every run emits one. + +Request: + +```json +{ + "action": "create|switch|delete|rename|track|list|get-intent", + "branch": "<branch-name>", + "base": "<base branch, optional, defaults to config>", + "intent": "<human-readable intent, optional>", + "confirm": "<true for destructive ops, omit for read ops>" +} +``` diff --git a/plugins/git/skills/git-branches/references/sources.md b/plugins/git/skills/git-branches/references/sources.md index d65c617..506756c 100644 --- a/plugins/git/skills/git-branches/references/sources.md +++ b/plugins/git/skills/git-branches/references/sources.md @@ -12,7 +12,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — Gitflow vs. GitHub Flow structure and defaults) +- SKILL.md (Gitflow vs. GitHub Flow inference and the not-mixable rule) +- references/branch-patterns.md (Gitflow vs. GitHub Flow structure, defaults, and why the two are not mixable) ## atlassian-gitflow-tutorial @@ -23,7 +24,9 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — Gitflow branch types, base/merge targets, `--no-ff` requirement) +- SKILL.md (Gitflow vs. GitHub Flow inference and the not-mixable rule) +- references/branch-patterns.md (Gitflow branch types, base/merge targets, `--no-ff` requirement) +- references/merging.md (`--no-ff` requirement on Gitflow supporting-branch merges) ## gitflow-cheatsheet @@ -34,7 +37,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/gitflow.md (whole-document reference) **Contributing files:** -- SKILL.md (Branch Patterns — feature/release/hotfix naming conventions) +- references/branch-patterns.md (feature/release/hotfix naming conventions) ## context7-git-htmldocs @@ -45,4 +48,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/branching-merging.md (whole-document reference) **Contributing files:** -- SKILL.md (Command mapping, Merging, Comparing Branches — `git switch`/`git branch`/`git merge`/`git log`/`git diff`/`git merge-base` command vocabulary and flags) +- SKILL.md (Gotchas — `git switch` abort-on-conflict behaviour, branch/tag name ambiguity) +- references/branch-operations.md (`git switch`/`git branch` command vocabulary and flags) +- references/merging.md (`git merge` strategies and conflict-resolution commands) +- references/comparing-branches.md (`git log`/`git diff`/`git merge-base` range syntax) -- 2.43.0 From 3dd5387671837a55a837b7fdcf2e702fe9a50587 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 16/89] refactor(git-worktrees): retrofit to the ADR-0020 context contract Description 592 -> 248 chars, body 756 -> 515 words, Gotchas 8 -> 4. The audit found the dispatch table had no row for a worktree on an existing local branch, so that request fell to the adjacent -B row, which resets the branch to HEAD and discards its commits. Non-destructive create is now the first row and -B names its own destructiveness. Adds the missing lock/unlock row and repair's run-from constraint. --- .../git/.apm/skills/git-worktrees/README.md | 6 +- .../git/.apm/skills/git-worktrees/SKILL.md | 132 +++++------------- .../skills/git-worktrees/references/README.md | 2 +- .../git-worktrees/references/sources.md | 4 +- .../git-worktrees/references/worktrees.md | 53 +++++++ plugins/git/skills/git-worktrees/README.md | 6 +- plugins/git/skills/git-worktrees/SKILL.md | 132 +++++------------- .../skills/git-worktrees/references/README.md | 2 +- .../git-worktrees/references/sources.md | 4 +- .../git-worktrees/references/worktrees.md | 53 +++++++ 10 files changed, 190 insertions(+), 204 deletions(-) diff --git a/plugins/git/.apm/skills/git-worktrees/README.md b/plugins/git/.apm/skills/git-worktrees/README.md index 14bb54b..2042f80 100644 --- a/plugins/git/.apm/skills/git-worktrees/README.md +++ b/plugins/git/.apm/skills/git-worktrees/README.md @@ -4,7 +4,7 @@ Manage git worktrees to enable multi-branch parallel development across isolated ## What it does -This skill handles worktree operations within the git workflow suite. It creates, lists, locks/unlocks, moves, removes, prunes, and repairs worktrees — letting an agent work on multiple branches simultaneously without stashing. It returns structured results (paths, branches, lock status) suitable for agent composition. +This skill handles worktree operations within the git workflow suite. It creates, lists, locks/unlocks, moves, removes, prunes, and repairs worktrees — letting an agent work on multiple branches simultaneously without stashing. It returns structured results (paths, branches, lock status) suitable for agent composition. For multi-step flows spanning branch strategy plus worktree setup, `git-workflow` handles the broader orchestration and delegates the worktree mechanics here. ## Usage @@ -18,7 +18,7 @@ Describe your worktree task: create a worktree for a branch, list existing workt | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Dispatch table, per-operation gates, and the report format | | `references/README.md` | Describes the references directory contents | -| `references/worktrees.md` | Full `add` flag table, sparse-checkout, removable-media locking, remote disambiguation, configuration | +| `references/worktrees.md` | Read when an operation needs more than the dispatch table: shared vs. per-worktree state, the `add` command forms, the full `add` flag table, orphan branches, sparse-checkout, removable-media locking, remote disambiguation, where to run `repair` from, config keys, and the emergency-fix and PR-review patterns | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index 5d735a5..51a1980 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -2,11 +2,10 @@ name: git-worktrees description: > - Manage Git worktrees to enable multi-branch parallel development across isolated directories. - Use when the user needs to work on multiple branches simultaneously without stashing, switch between feature/hotfix/experimental work, or coordinate code reviews alongside ongoing development. - Handles creation, listing, locking, moving, removal, pruning, and repair of worktrees. - Provides structured results (paths, branches, lock status) for agent composition in git orchestration workflows. - Do not use when only inspecting a single branch or when the user needs standard checkout/stash workflows. + Use when working on several branches at once without stashing. + Create, list, lock, move, remove, prune, or repair git worktrees. + Not ordinary branch switching or checkout -> `git-branches`. + Not interactive multi-step git guidance -> `git-workflow`. metadata: category: git @@ -14,101 +13,44 @@ metadata: - git-scm-worktree-docs --- -## Concept - -A worktree lets you check out multiple branches simultaneously from one repository, each in its own directory. All worktrees share the same objects, config, and most refs (`refs/`). Each worktree has its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `refs/bisect/`, `refs/worktree/`, `refs/rewritten/`) stored at `$GIT_DIR/worktrees/<name>/`. The **main worktree** (from `git init`/`git clone`) is exactly one per repo and cannot be removed; **linked worktrees** are the additional ones created via `git worktree add`. - ## Gotchas -- **A branch can only be checked out in one worktree at a time.** Attempting `git worktree add` for an already-checked-out branch fails unless you pass `--force`. Use `--force` only when intentional. -- **Submodules are unsupported and block operations.** Repos with submodules have incomplete worktree support. Worktrees containing submodules cannot be moved and require `--force` to remove. -- **Never manually `rm -rf` a worktree directory.** This leaves stale metadata in `$GIT_DIR/worktrees/`. Always use `git worktree remove`. If already deleted, run `git worktree prune` to clean up. -- **Manual moves break bidirectional pointers.** If a worktree directory is moved outside of `git worktree move`, run `git worktree repair` to fix connections. -- **Force-flag escalation with locks.** Removing or moving a locked worktree requires `-ff` (two flags), not just `-f`. -- **Worktree identification is by full path, unique basename, or unique partial path.** Ambiguous names error. Use `git worktree list` to see available identifiers. -- **`--lock` on `add` is atomic; create-then-lock has a race window.** Use `--lock` directly on `git worktree add` when consistency matters. -- **`extensions.worktreeConfig = true` is a one-way door.** It enables per-worktree config (`git config --worktree ...`) but makes the repo refuse to open in older Git versions. Once set, `core.bare`/`core.worktree` must live in `config.worktree`, not `config`. Don't enable it unless per-worktree config is actually needed. +- **A branch can be checked out in only one worktree at a time.** `git worktree add` on an already-checked-out branch fails; `--force` is the only override, so use it only deliberately. +- **Never `rm -rf` a worktree directory.** That strands metadata in `$GIT_DIR/worktrees/`. Use `git worktree remove`, or `git worktree prune` afterwards. +- **Submodules break worktree support.** A worktree containing submodules cannot be moved at all, and needs `--force` to remove. +- **`extensions.worktreeConfig = true` is a one-way door.** It costs compatibility with older Git and forces `core.bare`/`core.worktree` into `config.worktree`. Leave it off unless per-worktree config is needed. -## Common Operations +## Step 1 — Dispatch -**Create and switch to a new worktree** — default approach: -```bash -git worktree add -b <new-branch> <path> -cd <path> -``` -This creates a new branch and checks it out in a new directory. Other branches cannot be checked out elsewhere simultaneously. +| Operation | Run | +|---|---| +| Create on an existing branch | `git worktree add <path> <branch>` | +| Create on a new branch | `git worktree add -b <branch> <path>` | +| Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | +| Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | +| Create tracking a remote branch | `git worktree add <path> <remote>/<branch>` | +| Throwaway experiment, no branch | `git worktree add -d <path>` | +| List | `git worktree list -v`, or `--porcelain -z` to parse | +| Lock or unlock | `git worktree lock [--reason <str>] <path>` / `git worktree unlock <path>` | +| Move | `git worktree move <from> <to>` | +| Remove | `git worktree remove <path>` | +| Prune stale metadata | `git worktree prune --dry-run`, then without the flag | +| Repair after a manual move | `git worktree repair [<path>]` — from the main worktree to fix all links, or from the moved worktree itself | -**Create-or-reset a branch**: `git worktree add -B <branch> <path>` — like `-b` but resets the branch to HEAD if it already exists. +If the operation needs anything the table does not carry — the full `add` flag +table, orphan branches, sparse-checkout, locking for removable media, remote +disambiguation across several remotes, worktree config keys, or the worked +emergency-fix and PR-review patterns — read `references/worktrees.md`. -**Create a worktree for an existing remote branch**: -```bash -git worktree add <path> <remote>/<branch> -``` -For ambiguous names across remotes, disambiguate via `checkout.defaultRemote` config or `--guess-remote`. Full flag table and detail: `references/worktrees.md`. +Gates: -**Throwaway experiment in detached HEAD**: -```bash -git worktree add -d ../experiment # or --detach -# experiment freely, no branch created -git worktree remove ../experiment -``` +- **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. +- **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. +- **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. +- **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. -**List all worktrees with state**: -```bash -git worktree list -v # human-readable with lock/prune reasons -git worktree list --porcelain -z # machine-readable, NUL-terminated -``` +## Step 2 — Report -**Move a worktree to a new path**: -```bash -git worktree move <current-path> <new-path> -# Cannot move: main worktree, worktrees with submodules -# To override safeguards: -f; to override locked state too: -ff -``` - -**Remove a worktree**: -```bash -git worktree remove <path> # only if clean -git worktree remove -f <path> # force-remove unclean -git worktree remove -ff <path> # force-remove even if locked -``` - -**Prune stale metadata**: -```bash -git worktree prune --dry-run # preview what would be removed -git worktree prune # clean up orphaned metadata -``` -Also triggered by `git gc`, controlled by `gc.worktreePruneExpire` config. - -**Repair broken connections** (after a manual move): -```bash -git worktree repair # from main worktree or after it was moved -git worktree repair <path> # reconnect a specific linked worktree -``` - -Sparse-checkout worktrees, locking for removable media, the full `add` flag table, and the config key reference: `references/worktrees.md`. - -## Worked Examples - -**Emergency fix without disrupting current work** — no stashing needed, ongoing work in the main worktree is untouched: -```bash -git worktree add -b emergency-fix ../temp main -cd ../temp -# fix, commit -git commit -a -m "fix: critical production bug" -cd - -git worktree remove ../temp -``` - -**Review a PR branch alongside your current work** — no context switch, both branches stay checked out: -```bash -git worktree add ../review-pr-123 origin/feature-xyz -# open ../review-pr-123 in a second editor window or terminal -``` - -## Return Format for Agents - -When invoking worktree operations, return structured results: ```yaml worktrees: - path: <directory-path> @@ -116,10 +58,8 @@ worktrees: commit: <short-hash> locked: <true/false> lock_reason: <reason or empty> - - ... ``` -Derive these fields from `git worktree list --porcelain -z` — its `worktree`/`branch`/`HEAD`/`locked` lines map directly to `path`/`branch`/`commit`/`locked`+`lock_reason`. -For single operations, include the operation result (e.g., `created: true`, `removed: true`, `moved: true`). - -For multi-step flows spanning branch strategy plus worktree setup, compose with the `git-workflow` skill — it handles the broader orchestration, this skill handles the worktree mechanics. +Derive those fields from `git worktree list --porcelain -z`. For a single +operation, report its outcome instead — `created: true`, `moved: true`, +`removed: true`. diff --git a/plugins/git/.apm/skills/git-worktrees/references/README.md b/plugins/git/.apm/skills/git-worktrees/references/README.md index c8a0777..f5f6037 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/README.md +++ b/plugins/git/.apm/skills/git-worktrees/references/README.md @@ -10,4 +10,4 @@ This directory contains provenance metadata and research sources for the `git-wo ## Files - `sources.md` — Extracted research sources and their contributing documents -- `worktrees.md` — Full `add` flag table, sparse-checkout setup, removable-media locking, remote-branch disambiguation, and the config key reference +- `worktrees.md` — Shared vs. per-worktree state, the `add` command forms, the full `add` flag table, orphan branches, sparse-checkout setup, removable-media locking, remote-branch disambiguation, where to run `repair` from, the config key reference, and the emergency-fix and PR-review workflow patterns diff --git a/plugins/git/.apm/skills/git-worktrees/references/sources.md b/plugins/git/.apm/skills/git-worktrees/references/sources.md index b92c420..54fecc3 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/sources.md +++ b/plugins/git/.apm/skills/git-worktrees/references/sources.md @@ -12,5 +12,5 @@ - **Research doc:** plugins/git/docs/research/docs/git/worktrees.md (whole-document reference — covers `## Concept Overview`, `## Key Commands`, `## Workflow Patterns`, `## Common Gotchas`, `## Configuration`) **Contributing files:** -- SKILL.md (Concept, Gotchas, Common Operations, Worked Examples, Return Format) -- references/worktrees.md (full `add` flag table, sparse-checkout, removable media, remote disambiguation, configuration) +- SKILL.md (Gotchas, Step 1 dispatch table and per-operation gates, Step 2 report format) +- references/worktrees.md (shared vs. per-worktree state, `add` command forms, full `add` flag table, orphan branches, sparse-checkout, removable media, remote disambiguation, `repair` invocation directory, configuration, workflow patterns) diff --git a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md index b938211..ebade88 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md @@ -4,6 +4,27 @@ source_keys: - git-scm-worktree-docs --- +## Shared vs. per-worktree state + +All worktrees share one object store, one config, and most refs under `refs/`. Each worktree keeps +its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `refs/bisect/`, +`refs/worktree/`, `refs/rewritten/`) under `$GIT_DIR/worktrees/<name>/`. Exactly one **main +worktree** exists per repo — the one `git init` or `git clone` produced — and it cannot be removed +or moved. Every other worktree is a **linked worktree** created by `git worktree add`. + +## `add` forms + +```bash +git worktree add <path> <branch> # check out an existing branch — non-destructive +git worktree add -b <branch> <path> # create a new branch; fails if it exists +git worktree add <path> # branch named after $(basename <path>): checked out + # if it exists, else created from HEAD +git worktree add -B <branch> <path> # create the branch, or reset an existing one to HEAD, + # discarding the commits it carried +git worktree add <path> <remote>/<branch> # track a remote branch +git worktree add -d <path> # detached HEAD, no branch +``` + ## Full `add` flag table | Flag | Meaning | @@ -52,6 +73,16 @@ git worktree add <path> <remote>/<branch> ``` For ambiguous names across remotes, `checkout.defaultRemote` config disambiguates explicitly, or `--guess-remote` auto-matches by path basename (default controlled by `worktree.guessRemote` config). If a branch name matches multiple remotes during `worktree add` and neither is set, Git refuses rather than guessing. +## Repair after a manual move + +```bash +git worktree repair # run from the main worktree: fixes the links to every linked worktree +git worktree repair <path> # run from a moved linked worktree: fixes its own pointer back to main +``` + +`repair` reestablishes the bidirectional pointers a manual move breaks, but only for the side it is +run from. Run it from the wrong directory and it reports nothing and fixes nothing. + ## Configuration | Key | Effect | @@ -61,3 +92,25 @@ For ambiguous names across remotes, `checkout.defaultRemote` config disambiguate | `gc.worktreePruneExpire` | How long before stale worktree metadata is pruned by `git gc` | | `extensions.worktreeConfig` | Enable per-worktree config scope (`config.worktree` file) — see Gotchas in SKILL.md | | `checkout.defaultRemote` | Disambiguates which remote to use when a branch name matches multiple remotes during `worktree add` | + +## Workflow patterns + +**Emergency fix without disrupting current work** — nothing is stashed, and the main worktree is +untouched throughout: + +```bash +git worktree add -b emergency-fix ../temp main +cd ../temp +# fix, then commit +git commit -a -m "fix: critical production bug" +cd - +git worktree remove ../temp +``` + +**Review a PR branch alongside your own work** — both branches stay checked out, so there is no +context switch: + +```bash +git worktree add ../review-pr-123 origin/feature-xyz +# open ../review-pr-123 in a second editor window or terminal +``` diff --git a/plugins/git/skills/git-worktrees/README.md b/plugins/git/skills/git-worktrees/README.md index 14bb54b..2042f80 100644 --- a/plugins/git/skills/git-worktrees/README.md +++ b/plugins/git/skills/git-worktrees/README.md @@ -4,7 +4,7 @@ Manage git worktrees to enable multi-branch parallel development across isolated ## What it does -This skill handles worktree operations within the git workflow suite. It creates, lists, locks/unlocks, moves, removes, prunes, and repairs worktrees — letting an agent work on multiple branches simultaneously without stashing. It returns structured results (paths, branches, lock status) suitable for agent composition. +This skill handles worktree operations within the git workflow suite. It creates, lists, locks/unlocks, moves, removes, prunes, and repairs worktrees — letting an agent work on multiple branches simultaneously without stashing. It returns structured results (paths, branches, lock status) suitable for agent composition. For multi-step flows spanning branch strategy plus worktree setup, `git-workflow` handles the broader orchestration and delegates the worktree mechanics here. ## Usage @@ -18,7 +18,7 @@ Describe your worktree task: create a worktree for a branch, list existing workt | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Dispatch table, per-operation gates, and the report format | | `references/README.md` | Describes the references directory contents | -| `references/worktrees.md` | Full `add` flag table, sparse-checkout, removable-media locking, remote disambiguation, configuration | +| `references/worktrees.md` | Read when an operation needs more than the dispatch table: shared vs. per-worktree state, the `add` command forms, the full `add` flag table, orphan branches, sparse-checkout, removable-media locking, remote disambiguation, where to run `repair` from, config keys, and the emergency-fix and PR-review patterns | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/skills/git-worktrees/SKILL.md b/plugins/git/skills/git-worktrees/SKILL.md index 5d735a5..51a1980 100644 --- a/plugins/git/skills/git-worktrees/SKILL.md +++ b/plugins/git/skills/git-worktrees/SKILL.md @@ -2,11 +2,10 @@ name: git-worktrees description: > - Manage Git worktrees to enable multi-branch parallel development across isolated directories. - Use when the user needs to work on multiple branches simultaneously without stashing, switch between feature/hotfix/experimental work, or coordinate code reviews alongside ongoing development. - Handles creation, listing, locking, moving, removal, pruning, and repair of worktrees. - Provides structured results (paths, branches, lock status) for agent composition in git orchestration workflows. - Do not use when only inspecting a single branch or when the user needs standard checkout/stash workflows. + Use when working on several branches at once without stashing. + Create, list, lock, move, remove, prune, or repair git worktrees. + Not ordinary branch switching or checkout -> `git-branches`. + Not interactive multi-step git guidance -> `git-workflow`. metadata: category: git @@ -14,101 +13,44 @@ metadata: - git-scm-worktree-docs --- -## Concept - -A worktree lets you check out multiple branches simultaneously from one repository, each in its own directory. All worktrees share the same objects, config, and most refs (`refs/`). Each worktree has its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `refs/bisect/`, `refs/worktree/`, `refs/rewritten/`) stored at `$GIT_DIR/worktrees/<name>/`. The **main worktree** (from `git init`/`git clone`) is exactly one per repo and cannot be removed; **linked worktrees** are the additional ones created via `git worktree add`. - ## Gotchas -- **A branch can only be checked out in one worktree at a time.** Attempting `git worktree add` for an already-checked-out branch fails unless you pass `--force`. Use `--force` only when intentional. -- **Submodules are unsupported and block operations.** Repos with submodules have incomplete worktree support. Worktrees containing submodules cannot be moved and require `--force` to remove. -- **Never manually `rm -rf` a worktree directory.** This leaves stale metadata in `$GIT_DIR/worktrees/`. Always use `git worktree remove`. If already deleted, run `git worktree prune` to clean up. -- **Manual moves break bidirectional pointers.** If a worktree directory is moved outside of `git worktree move`, run `git worktree repair` to fix connections. -- **Force-flag escalation with locks.** Removing or moving a locked worktree requires `-ff` (two flags), not just `-f`. -- **Worktree identification is by full path, unique basename, or unique partial path.** Ambiguous names error. Use `git worktree list` to see available identifiers. -- **`--lock` on `add` is atomic; create-then-lock has a race window.** Use `--lock` directly on `git worktree add` when consistency matters. -- **`extensions.worktreeConfig = true` is a one-way door.** It enables per-worktree config (`git config --worktree ...`) but makes the repo refuse to open in older Git versions. Once set, `core.bare`/`core.worktree` must live in `config.worktree`, not `config`. Don't enable it unless per-worktree config is actually needed. +- **A branch can be checked out in only one worktree at a time.** `git worktree add` on an already-checked-out branch fails; `--force` is the only override, so use it only deliberately. +- **Never `rm -rf` a worktree directory.** That strands metadata in `$GIT_DIR/worktrees/`. Use `git worktree remove`, or `git worktree prune` afterwards. +- **Submodules break worktree support.** A worktree containing submodules cannot be moved at all, and needs `--force` to remove. +- **`extensions.worktreeConfig = true` is a one-way door.** It costs compatibility with older Git and forces `core.bare`/`core.worktree` into `config.worktree`. Leave it off unless per-worktree config is needed. -## Common Operations +## Step 1 — Dispatch -**Create and switch to a new worktree** — default approach: -```bash -git worktree add -b <new-branch> <path> -cd <path> -``` -This creates a new branch and checks it out in a new directory. Other branches cannot be checked out elsewhere simultaneously. +| Operation | Run | +|---|---| +| Create on an existing branch | `git worktree add <path> <branch>` | +| Create on a new branch | `git worktree add -b <branch> <path>` | +| Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | +| Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | +| Create tracking a remote branch | `git worktree add <path> <remote>/<branch>` | +| Throwaway experiment, no branch | `git worktree add -d <path>` | +| List | `git worktree list -v`, or `--porcelain -z` to parse | +| Lock or unlock | `git worktree lock [--reason <str>] <path>` / `git worktree unlock <path>` | +| Move | `git worktree move <from> <to>` | +| Remove | `git worktree remove <path>` | +| Prune stale metadata | `git worktree prune --dry-run`, then without the flag | +| Repair after a manual move | `git worktree repair [<path>]` — from the main worktree to fix all links, or from the moved worktree itself | -**Create-or-reset a branch**: `git worktree add -B <branch> <path>` — like `-b` but resets the branch to HEAD if it already exists. +If the operation needs anything the table does not carry — the full `add` flag +table, orphan branches, sparse-checkout, locking for removable media, remote +disambiguation across several remotes, worktree config keys, or the worked +emergency-fix and PR-review patterns — read `references/worktrees.md`. -**Create a worktree for an existing remote branch**: -```bash -git worktree add <path> <remote>/<branch> -``` -For ambiguous names across remotes, disambiguate via `checkout.defaultRemote` config or `--guess-remote`. Full flag table and detail: `references/worktrees.md`. +Gates: -**Throwaway experiment in detached HEAD**: -```bash -git worktree add -d ../experiment # or --detach -# experiment freely, no branch created -git worktree remove ../experiment -``` +- **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. +- **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. +- **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. +- **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. -**List all worktrees with state**: -```bash -git worktree list -v # human-readable with lock/prune reasons -git worktree list --porcelain -z # machine-readable, NUL-terminated -``` +## Step 2 — Report -**Move a worktree to a new path**: -```bash -git worktree move <current-path> <new-path> -# Cannot move: main worktree, worktrees with submodules -# To override safeguards: -f; to override locked state too: -ff -``` - -**Remove a worktree**: -```bash -git worktree remove <path> # only if clean -git worktree remove -f <path> # force-remove unclean -git worktree remove -ff <path> # force-remove even if locked -``` - -**Prune stale metadata**: -```bash -git worktree prune --dry-run # preview what would be removed -git worktree prune # clean up orphaned metadata -``` -Also triggered by `git gc`, controlled by `gc.worktreePruneExpire` config. - -**Repair broken connections** (after a manual move): -```bash -git worktree repair # from main worktree or after it was moved -git worktree repair <path> # reconnect a specific linked worktree -``` - -Sparse-checkout worktrees, locking for removable media, the full `add` flag table, and the config key reference: `references/worktrees.md`. - -## Worked Examples - -**Emergency fix without disrupting current work** — no stashing needed, ongoing work in the main worktree is untouched: -```bash -git worktree add -b emergency-fix ../temp main -cd ../temp -# fix, commit -git commit -a -m "fix: critical production bug" -cd - -git worktree remove ../temp -``` - -**Review a PR branch alongside your current work** — no context switch, both branches stay checked out: -```bash -git worktree add ../review-pr-123 origin/feature-xyz -# open ../review-pr-123 in a second editor window or terminal -``` - -## Return Format for Agents - -When invoking worktree operations, return structured results: ```yaml worktrees: - path: <directory-path> @@ -116,10 +58,8 @@ worktrees: commit: <short-hash> locked: <true/false> lock_reason: <reason or empty> - - ... ``` -Derive these fields from `git worktree list --porcelain -z` — its `worktree`/`branch`/`HEAD`/`locked` lines map directly to `path`/`branch`/`commit`/`locked`+`lock_reason`. -For single operations, include the operation result (e.g., `created: true`, `removed: true`, `moved: true`). - -For multi-step flows spanning branch strategy plus worktree setup, compose with the `git-workflow` skill — it handles the broader orchestration, this skill handles the worktree mechanics. +Derive those fields from `git worktree list --porcelain -z`. For a single +operation, report its outcome instead — `created: true`, `moved: true`, +`removed: true`. diff --git a/plugins/git/skills/git-worktrees/references/README.md b/plugins/git/skills/git-worktrees/references/README.md index c8a0777..f5f6037 100644 --- a/plugins/git/skills/git-worktrees/references/README.md +++ b/plugins/git/skills/git-worktrees/references/README.md @@ -10,4 +10,4 @@ This directory contains provenance metadata and research sources for the `git-wo ## Files - `sources.md` — Extracted research sources and their contributing documents -- `worktrees.md` — Full `add` flag table, sparse-checkout setup, removable-media locking, remote-branch disambiguation, and the config key reference +- `worktrees.md` — Shared vs. per-worktree state, the `add` command forms, the full `add` flag table, orphan branches, sparse-checkout setup, removable-media locking, remote-branch disambiguation, where to run `repair` from, the config key reference, and the emergency-fix and PR-review workflow patterns diff --git a/plugins/git/skills/git-worktrees/references/sources.md b/plugins/git/skills/git-worktrees/references/sources.md index b92c420..54fecc3 100644 --- a/plugins/git/skills/git-worktrees/references/sources.md +++ b/plugins/git/skills/git-worktrees/references/sources.md @@ -12,5 +12,5 @@ - **Research doc:** plugins/git/docs/research/docs/git/worktrees.md (whole-document reference — covers `## Concept Overview`, `## Key Commands`, `## Workflow Patterns`, `## Common Gotchas`, `## Configuration`) **Contributing files:** -- SKILL.md (Concept, Gotchas, Common Operations, Worked Examples, Return Format) -- references/worktrees.md (full `add` flag table, sparse-checkout, removable media, remote disambiguation, configuration) +- SKILL.md (Gotchas, Step 1 dispatch table and per-operation gates, Step 2 report format) +- references/worktrees.md (shared vs. per-worktree state, `add` command forms, full `add` flag table, orphan branches, sparse-checkout, removable media, remote disambiguation, `repair` invocation directory, configuration, workflow patterns) diff --git a/plugins/git/skills/git-worktrees/references/worktrees.md b/plugins/git/skills/git-worktrees/references/worktrees.md index b938211..ebade88 100644 --- a/plugins/git/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/skills/git-worktrees/references/worktrees.md @@ -4,6 +4,27 @@ source_keys: - git-scm-worktree-docs --- +## Shared vs. per-worktree state + +All worktrees share one object store, one config, and most refs under `refs/`. Each worktree keeps +its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `refs/bisect/`, +`refs/worktree/`, `refs/rewritten/`) under `$GIT_DIR/worktrees/<name>/`. Exactly one **main +worktree** exists per repo — the one `git init` or `git clone` produced — and it cannot be removed +or moved. Every other worktree is a **linked worktree** created by `git worktree add`. + +## `add` forms + +```bash +git worktree add <path> <branch> # check out an existing branch — non-destructive +git worktree add -b <branch> <path> # create a new branch; fails if it exists +git worktree add <path> # branch named after $(basename <path>): checked out + # if it exists, else created from HEAD +git worktree add -B <branch> <path> # create the branch, or reset an existing one to HEAD, + # discarding the commits it carried +git worktree add <path> <remote>/<branch> # track a remote branch +git worktree add -d <path> # detached HEAD, no branch +``` + ## Full `add` flag table | Flag | Meaning | @@ -52,6 +73,16 @@ git worktree add <path> <remote>/<branch> ``` For ambiguous names across remotes, `checkout.defaultRemote` config disambiguates explicitly, or `--guess-remote` auto-matches by path basename (default controlled by `worktree.guessRemote` config). If a branch name matches multiple remotes during `worktree add` and neither is set, Git refuses rather than guessing. +## Repair after a manual move + +```bash +git worktree repair # run from the main worktree: fixes the links to every linked worktree +git worktree repair <path> # run from a moved linked worktree: fixes its own pointer back to main +``` + +`repair` reestablishes the bidirectional pointers a manual move breaks, but only for the side it is +run from. Run it from the wrong directory and it reports nothing and fixes nothing. + ## Configuration | Key | Effect | @@ -61,3 +92,25 @@ For ambiguous names across remotes, `checkout.defaultRemote` config disambiguate | `gc.worktreePruneExpire` | How long before stale worktree metadata is pruned by `git gc` | | `extensions.worktreeConfig` | Enable per-worktree config scope (`config.worktree` file) — see Gotchas in SKILL.md | | `checkout.defaultRemote` | Disambiguates which remote to use when a branch name matches multiple remotes during `worktree add` | + +## Workflow patterns + +**Emergency fix without disrupting current work** — nothing is stashed, and the main worktree is +untouched throughout: + +```bash +git worktree add -b emergency-fix ../temp main +cd ../temp +# fix, then commit +git commit -a -m "fix: critical production bug" +cd - +git worktree remove ../temp +``` + +**Review a PR branch alongside your own work** — both branches stay checked out, so there is no +context switch: + +```bash +git worktree add ../review-pr-123 origin/feature-xyz +# open ../review-pr-123 in a second editor window or terminal +``` -- 2.43.0 From 38eb0745b7a5eddf55776ddb2906c4e84702edc4 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 17/89] refactor(git-remotes): retrofit to the ADR-0020 context contract Description 582 -> 237 chars, body 1217 -> 290 words. The single remotes.md splits into config, fetch, push, and pull flow files. Restores the confirm: true token to the force-push gate -- it is the git plugin's cross-skill contract, gated on by git-orchestrate and git-branches. Moves push.md's worked example off main, which the skill's own Step 1 refuses, and restores the never-bare---force directive. --- plugins/git/.apm/skills/git-remotes/README.md | 14 ++- plugins/git/.apm/skills/git-remotes/SKILL.md | 106 ++++-------------- .../skills/git-remotes/references/README.md | 5 +- .../skills/git-remotes/references/fetch.md | 29 +++++ .../skills/git-remotes/references/pull.md | 37 ++++++ .../skills/git-remotes/references/push.md | 67 +++++++++++ .../git-remotes/references/remote-config.md | 39 +++++++ .../skills/git-remotes/references/remotes.md | 82 -------------- .../skills/git-remotes/references/sources.md | 19 ++-- plugins/git/skills/git-remotes/README.md | 14 ++- plugins/git/skills/git-remotes/SKILL.md | 106 ++++-------------- .../skills/git-remotes/references/README.md | 5 +- .../skills/git-remotes/references/fetch.md | 29 +++++ .../git/skills/git-remotes/references/pull.md | 37 ++++++ .../git/skills/git-remotes/references/push.md | 67 +++++++++++ .../git-remotes/references/remote-config.md | 39 +++++++ .../skills/git-remotes/references/remotes.md | 82 -------------- .../skills/git-remotes/references/sources.md | 19 ++-- 18 files changed, 440 insertions(+), 356 deletions(-) create mode 100644 plugins/git/.apm/skills/git-remotes/references/fetch.md create mode 100644 plugins/git/.apm/skills/git-remotes/references/pull.md create mode 100644 plugins/git/.apm/skills/git-remotes/references/push.md create mode 100644 plugins/git/.apm/skills/git-remotes/references/remote-config.md delete mode 100644 plugins/git/.apm/skills/git-remotes/references/remotes.md create mode 100644 plugins/git/skills/git-remotes/references/fetch.md create mode 100644 plugins/git/skills/git-remotes/references/pull.md create mode 100644 plugins/git/skills/git-remotes/references/push.md create mode 100644 plugins/git/skills/git-remotes/references/remote-config.md delete mode 100644 plugins/git/skills/git-remotes/references/remotes.md diff --git a/plugins/git/.apm/skills/git-remotes/README.md b/plugins/git/.apm/skills/git-remotes/README.md index 33382a1..0895aa2 100644 --- a/plugins/git/.apm/skills/git-remotes/README.md +++ b/plugins/git/.apm/skills/git-remotes/README.md @@ -18,7 +18,17 @@ Describe your remote operation: add a remote, push, pull, fetch, or configure tr | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — force-push gate, dispatch table, return format | | `references/README.md` | Describes the references directory contents | -| `references/remotes.md` | Full `set-url` variants, shallow-clone/fetch options, force-push mitigation detail, and pull config precedence | +| `references/remote-config.md` | Read when adding, removing, renaming, inspecting or re-pointing a remote, or configuring tracking, mirroring, or `set-url` | +| `references/fetch.md` | Read when fetching or pruning remote-tracking refs, or doing a shallow or partial fetch | +| `references/push.md` | Read when pushing branches or tags, writing refspecs, or force-pushing | +| `references/pull.md` | Read when integrating remote changes into the current branch, including the divergence rule | | `references/sources.md` | Research sources and provenance | + +## Composition + +Callers that need submodule initialization after a `--recurse-submodules` pull hand off to +`git-submodules`; local-only work (commits, branches, history) belongs to `git-commits`, +`git-branches`, and `git-history`. The `git-workflow` skill routes humans here for any +remote-touching request. diff --git a/plugins/git/.apm/skills/git-remotes/SKILL.md b/plugins/git/.apm/skills/git-remotes/SKILL.md index 9163466..5cc0eca 100644 --- a/plugins/git/.apm/skills/git-remotes/SKILL.md +++ b/plugins/git/.apm/skills/git-remotes/SKILL.md @@ -2,13 +2,11 @@ name: git-remotes description: > - Manage git remote repositories — add/remove/configure remotes, push/pull with safety checks, - handle fetch patterns and tracking branch updates, support multi-remote workflows. - Use when automating remote operations, pushing with force-push safety, fetching with pruning, - pulling with divergence resolution, or managing multi-remote tracking. Include indirect triggers: - any git operation that touches a remote, even if the user doesn't explicitly name the remote. - Do not use when working with local git history, commits, branches, or staging — use git-history - or git-branches instead. + Use when a git operation — remote config, fetch, push, or pull — touches a + remote, even when the user does not name it. + Not local commits -> `git-commits`. + Not local branches -> `git-branches`. + Not submodule pointers -> `git-submodules`. metadata: category: git-workflow @@ -23,91 +21,29 @@ metadata: ## Gotchas -- **Never force-push `main` or `master`, under any circumstances** — this is a hard refusal, not a `confirm: true` gate. If a force-push targets one of these branches, decline and explain why, regardless of how the request is confirmed. -- **Force-push to any other branch requires explicit confirmation** — never execute `git push --force` or `git push -f` without user/agent approval. Always ask or require `confirm: true` flag first. -- **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) that run `git fetch` silently defeat the protection. Always combine with `--force-if-includes` or use explicit SHA form `--force-with-lease=<ref>:<sha>`. -- **Prune doesn't touch tags by default** — `git fetch --prune` leaves orphaned tags. Use `git fetch --prune --prune-tags` or configure `fetch.pruneTags true` globally. -- **Pull with rebase rewrites history** — only safe for unpublished work. Rebasing already-pushed commits breaks everyone downstream. Check what's been pushed before rebasing. -- **`git remote show` requires network access** — use `-n` flag for cached data if working offline. `git remote -v` lists URLs without network queries. -- **Pull behavior defaults shift between Git versions** — older versions default to merge, newer versions to `--ff-only`. Always set `pull.ff only` explicitly for deterministic behavior. +- **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) running `git fetch` silently defeat the protection. Combine it with `--force-if-includes`, or pin the explicit `--force-with-lease=<ref>:<sha>` form. +- **Prune does not touch tags by default** — `git fetch --prune` leaves orphaned tags behind. Use `--prune --prune-tags`, or set `fetch.pruneTags true`. +- **Pull defaults shift between Git versions** — older ones default to merge, newer to `--ff-only`. Set `pull.ff only` explicitly rather than trusting the installed default. -## Operations +## Step 1 — Clear the force-push gate -### Remote Management +`main` and `master` are a hard refusal: decline a force-push targeting either, whatever confirmation accompanies it, because no local approval can restore what the remote loses. On any other branch, `git push --force` and `-f` run only after the caller passes `confirm: true` for that specific push — for a human caller, prompt instead of failing. -Use these to configure which remotes you push to and pull from: +## Step 2 — Dispatch -- **Add a remote**: `git remote add <name> <url>` or `git remote add -f <name> <url>` to fetch immediately -- **Remove a remote**: `git remote remove <name>` (deletes remote + all tracking refs + config) -- **Rename a remote**: `git remote rename <old> <new>` -- **Inspect remotes**: `git remote -v` (show URLs) or `git remote show <name>` (live tracking status, requires network) -- **Set-url separately for fetch vs. push**: `git remote set-url --push <name> <url>` changes only where pushes go — but fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead; `--push` cannot do this. Full `set-url` variants (regex-targeted replace, `--add`, `--delete`): `references/remotes.md`. -- **Remove a stale URL**: `git remote set-url --delete <name> <regex>` -- **Inspect effective URLs**: `git remote get-url <name>` (shows URL after `insteadOf` rewrites) or `git remote get-url --push --all <name>` (all push URLs) -- **Track only one branch**: `git remote add -t <branch> <name> <url>` (repeatable), or suppress tag import entirely with `git remote add --no-tags <name> <url>` -- **Mirror a remote**: `git remote add --mirror=fetch <name> <url>` mirrors all refs locally (bare repos only); `--mirror=push` makes every push behave like `--mirror` -- **Prune stale tracking refs without fetching**: `git remote prune <name>` (add `--dry-run` to preview first) -- **Set the remote's default branch pointer**: `git remote set-head <name> -a` (auto-detect, requires a prior fetch), `git remote set-head <name> <branch>` (explicit), or `git remote set-head <name> -d` (delete `refs/remotes/<name>/HEAD`) +Read the row matching the operation, and only that row — each file is self-contained. A task spanning two operations reads both. -### Fetch Operations - -Use these to update your tracking branches without touching your local branches: - -- **Fetch from one remote**: `git fetch <remote>` — fetches all branches -- **Fetch one branch only**: `git fetch <remote> <branch>` — stores the result in `FETCH_HEAD`, not a tracking ref -- **Fetch from all remotes**: `git fetch --all` with optional `--prune` to clean up stale tracking refs -- **Prune properly**: Use `git fetch --all --prune --prune-tags` to clean both branches and tags -- **Configure auto-prune**: Set `git config --global fetch.prune true` to auto-prune on every fetch across all remotes (or `remote.<name>.prune` to scope it to one remote) -- **Shallow clones**: `--depth=<n>` to deepen or create a shallow clone, `--unshallow` to convert to full history, `--update-shallow` to allow the shallow boundary to move. Details and the default fetch refspec: `references/remotes.md`. - -Fetch never modifies your local branches — it only updates remote-tracking branches (`refs/remotes/origin/*`). - -### Push Operations - -Use these to send your commits upstream. Default: safe push to same-named branch on the remote. - -- **Basic push**: `git push <remote> <branch>` — pushes to same-named remote branch -- **Set upstream**: `git push -u <remote> <branch>` — push and configure this branch to track the remote -- **Multi-remote push**: `git push origin develop` and `git push staging develop` sequentially, or use `git remote set-url --add <name> <url>` to push to multiple remotes with one command -- **Force-push safety**: Always use `git push --force-with-lease --force-if-includes <remote> <branch>` over bare `--force`. Require explicit confirmation first — and never for `main`/`master` (see Gotchas). `--force-if-includes` is a no-op without `--force-with-lease`. If background tools (IDE, cron) auto-fetch and could poison the lease check, use a dedicated push-only remote instead — see `references/remotes.md`. -- **Server-side enforcement**: `receive.denyDeletes`, `receive.denyDeleteCurrent`, and `receive.denyNonFastForwards` are enforced on the remote regardless of local flags — a hardened server rejects the push even with `--force`. -- **Delete remote branch**: `git push <remote> --delete <branch>` (not `:<branch>` syntax; clearer and cleaner) -- **Push everything**: `git push --all` (all local branches) or `git push --tags` (all tags) -- **Push a single tag**: `git push origin <tag>` -- **Delete remote branches with no local counterpart**: `git push --prune origin 'refs/heads/*:refs/heads/*'` -- **Force only part of a multi-ref push**: prefix the one refspec that needs it with `+`, e.g. `git push origin +main develop` forces `main` while safe-pushing `develop` - -Refspec syntax is `[+]<src>[:<dst>]`: - -| Pattern | Meaning | +| Operation | Read | |---|---| -| `<branch>` | Push to same-named remote branch | -| `<src>:<dst>` | Push `<src>` local ref to `<dst>` remote ref | -| `+<src>:<dst>` | Force this refspec (non-fast-forward allowed) | -| `:<branch>` | Delete remote `<branch>` | -| `refs/heads/*:refs/heads/*` | Glob: push all matching branches | -| `^refs/heads/dev-*` | Negative: exclude matching refs | -| `tag <name>` | Sugar for `refs/tags/<name>:refs/tags/<name>` | +| Add, remove, rename, inspect, or re-point a remote; tracking, mirror, and `set-url` config | `references/remote-config.md` | +| Fetch or prune remote-tracking refs; shallow or partial fetch | `references/fetch.md` | +| Push branches or tags; refspecs; force-push | `references/push.md` | +| Pull — integrate remote changes into the current branch | `references/pull.md` | -### Pull Operations +## Step 3 — Return format -Use these to fetch and integrate remote changes. Default strategy: `--ff-only` (fail if diverged, forcing a conscious choice). +For agent callers, return: -- **Pull with fast-forward only**: `git pull --ff-only` (recommended default — fails if you've diverged, forcing a rebase/merge decision) -- **Pull with rebase**: `git pull --rebase` (replays your unpublished commits on top; linear history, but rewrites SHAs — only safe for unpublished work) -- **Pull with merge**: `git pull --no-rebase` (three-way merge commit; preserves original commits, non-linear) -- **Pull with rebase, preserving merges**: `git pull --rebase=merges` (like `--rebase`, but keeps intentional local merge commits during replay) -- **Pull without integrating**: `git pull --squash` collapses incoming commits into staged changes without committing — you write the commit message -- **Set pull strategy globally**: `git config pull.ff only` (or `pull.rebase true`; respects branch-specific overrides via `branch.<name>.rebase`). Full precedence order (CLI flag > `pull.rebase` > `branch.<name>.rebase` > `branch.autoSetupRebase`): `references/remotes.md`. -- **Check before rebasing**: Always verify your commits haven't been pushed before using `--rebase`. Rebasing published commits breaks everyone downstream. -- **Merge strategy default**: Git 2.34+ defaults to the `ort` merge strategy (`recursive` is now just an alias for it). Strategy options like `-X ours`, `-X theirs`, `-X ignore-space-change` still pass through unchanged. -- **Submodules on pull**: `--recurse-submodules` only fetches submodules that are already checked out — newly added submodules are not initialized automatically. Use the `git-submodules` skill to initialize new ones. - -If pull diverges and you haven't set a strategy, the operation fails — this is good, forces a conscious choice. Never auto-merge diverged branches without asking. - -### Return Format (for agents) - -Return structured output: ```json { "success": true, @@ -116,8 +52,8 @@ Return structured output: "branch": "main", "output": "...", "warnings": ["force-with-lease not confirmed"], - "recommendations": ["set pull.ff=only globally"] + "recommendations": ["set `pull.ff only` so the default does not vary by Git version"] } ``` -On failure, include `error` field with root cause and recovery suggestion. +On failure, set `success: false` and add an `error` field holding the root cause and a recovery suggestion. diff --git a/plugins/git/.apm/skills/git-remotes/references/README.md b/plugins/git/.apm/skills/git-remotes/references/README.md index ea38459..a9ca455 100644 --- a/plugins/git/.apm/skills/git-remotes/references/README.md +++ b/plugins/git/.apm/skills/git-remotes/references/README.md @@ -14,4 +14,7 @@ This directory contains provenance metadata and research sources for the `git-re ## Files - `sources.md` — Extracted research sources and their contributing documents -- `remotes.md` — Full `set-url` variants, shallow-clone/fetch options, default fetch refspec, force-push mitigation detail, server-side deny policies, and pull config precedence +- `remote-config.md` — Remote add/remove/rename/inspect, tracking and mirror options, housekeeping, and the full `set-url` form +- `fetch.md` — Fetch and prune options, shallow and partial fetch, the default fetch refspec +- `push.md` — Push options, refspec syntax, force-push safety in full, server-side deny policies +- `pull.md` — Pull strategies, submodule caveat, the divergence rule, and pull config precedence diff --git a/plugins/git/.apm/skills/git-remotes/references/fetch.md b/plugins/git/.apm/skills/git-remotes/references/fetch.md new file mode 100644 index 0000000..e0c05ff --- /dev/null +++ b/plugins/git/.apm/skills/git-remotes/references/fetch.md @@ -0,0 +1,29 @@ +--- +topic: fetch +source_keys: + - git-scm-fetch-docs + - context7-git-htmldocs +--- + +# Fetching + +Fetch updates remote-tracking branches (`refs/remotes/<name>/*`) and never modifies a local branch, so it is always safe to run. + +- **One remote**: `git fetch <remote>` — all branches +- **One branch**: `git fetch <remote> <branch>` — the result lands in `FETCH_HEAD`, not a tracking ref +- **All remotes**: `git fetch --all` +- **Prune properly**: `git fetch --all --prune --prune-tags` cleans stale branches *and* tags +- **Auto-prune**: `git config --global fetch.prune true` (or `remote.<name>.prune` to scope it to one remote), and `fetch.pruneTags true` for tags + +## Shallow and partial fetch + +```bash +git fetch --depth=<n> # deepen history, or create a shallow clone +git fetch --unshallow # convert a shallow clone to full history +git fetch --update-shallow # allow the fetch to update the shallow boundary +git fetch --refmap='' <remote> <branch> # fetch without updating any tracking ref (FETCH_HEAD only) +``` + +## Default fetch refspec + +The default is `+refs/heads/*:refs/remotes/<name>/*`. The leading `+` forces the update — remote-tracking branches always mirror the remote exactly and offer no protection for local history. diff --git a/plugins/git/.apm/skills/git-remotes/references/pull.md b/plugins/git/.apm/skills/git-remotes/references/pull.md new file mode 100644 index 0000000..3a8a25a --- /dev/null +++ b/plugins/git/.apm/skills/git-remotes/references/pull.md @@ -0,0 +1,37 @@ +--- +topic: pull +source_keys: + - git-scm-pull-docs + - context7-git-htmldocs +--- + +# Pulling + +Default strategy: `--ff-only`. It fails on divergence, which forces a conscious choice instead of an accidental merge commit. + +- **Fast-forward only**: `git pull --ff-only` — the recommended default +- **Rebase**: `git pull --rebase` replays your commits on top for linear history, but rewrites SHAs. Verify nothing being replayed has been pushed: rebasing published commits breaks everyone downstream. +- **Merge**: `git pull --no-rebase` — three-way merge commit, preserves original commits, non-linear +- **Rebase preserving merges**: `git pull --rebase=merges` keeps intentional local merge commits during the replay +- **Stage without committing**: `git pull --squash` collapses incoming commits into staged changes; you write the message +- **Merge strategy**: Git 2.34+ defaults to `ort` (`recursive` is now an alias for it). Strategy options such as `-X ours`, `-X theirs`, `-X ignore-space-change` pass through unchanged. +- **Submodules**: `--recurse-submodules` only fetches submodules already checked out. Newly added ones are not initialized — use the `git-submodules` skill for those. + +## On divergence + +A pull that diverges with no strategy configured fails, and that failure is the useful outcome. Report the divergence and the three ways out — `--ff-only`, `--rebase`, `--no-rebase` — and let the caller choose. Auto-merging a diverged branch buries a decision that belongs to the human. + +## Config precedence + +Highest wins: + +1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) +2. `pull.rebase` config (global or local) +3. `branch.<name>.rebase` (branch-specific override) +4. `branch.autoSetupRebase` (set automatically when the tracking branch was created) + +```bash +git config pull.ff only # deterministic default across Git versions +git config --global pull.rebase true +git config branch.develop.rebase false # develop always merges, regardless of the global default +``` diff --git a/plugins/git/.apm/skills/git-remotes/references/push.md b/plugins/git/.apm/skills/git-remotes/references/push.md new file mode 100644 index 0000000..dd032fb --- /dev/null +++ b/plugins/git/.apm/skills/git-remotes/references/push.md @@ -0,0 +1,67 @@ +--- +topic: push +source_keys: + - git-scm-push-docs + - context7-git-htmldocs +--- + +# Pushing + +Default: safe push to the same-named branch on the remote. + +- **Force-push**: never bare `--force`. Use `git push --force-with-lease --force-if-includes <remote> <branch>`, after the SKILL.md Step 1 gate. +- **Basic**: `git push <remote> <branch>` +- **Set upstream**: `git push -u <remote> <branch>` — push and configure tracking +- **Multi-remote**: push sequentially (`git push origin develop`, `git push staging develop`), or add a second push URL with `git remote set-url --add <name> <url>` to reach both in one command +- **Delete a remote branch**: `git push <remote> --delete <branch>` — clearer than the `:<branch>` form +- **Bulk**: `git push --all` (all local branches), `git push --tags` (all tags), `git push origin <tag>` (one tag) +- **Delete remote branches with no local counterpart**: `git push --prune origin 'refs/heads/*:refs/heads/*'` +- **Force only part of a multi-ref push**: prefix the one refspec that needs it with `+` — `git push origin +release develop` forces `release` while safe-pushing `develop`. A `+` prefix is a force-push and passes the SKILL.md Step 1 gate like any other. + +## Refspec syntax — `[+]<src>[:<dst>]` + +| Pattern | Meaning | +|---|---| +| `<branch>` | Push to same-named remote branch | +| `<src>:<dst>` | Push `<src>` local ref to `<dst>` remote ref | +| `+<src>:<dst>` | Force this refspec (non-fast-forward allowed) — a force-push; passes the SKILL.md Step 1 gate | +| `:<branch>` | Delete remote `<branch>` | +| `refs/heads/*:refs/heads/*` | Glob: push all matching branches | +| `^refs/heads/dev-*` | Negative: exclude matching refs | +| `tag <name>` | Sugar for `refs/tags/<name>:refs/tags/<name>` | + +## Force-push safety — full detail + +`--force-with-lease` rejects the push if the remote ref moved since your last fetch. Three forms: + +| Form | What it protects | +|---|---| +| `--force-with-lease` (bare) | All refs being pushed, checked against your remote-tracking branch | +| `--force-with-lease=<refname>` | Named ref only | +| `--force-with-lease=<refname>:<sha>` | Named ref must be at exact SHA — most stable | + +**Caveat with the bare form:** any background process that runs `git fetch` (IDE plugin, cron job, editor auto-fetch) updates your remote-tracking branch, which can make the lease check pass even though someone else pushed in between. The protection is silently defeated. + +Two mitigations: + +```bash +# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push +# through a separate remote that nothing else touches, so its tracking ref can't be +# poisoned by an unrelated fetch. +git remote add origin-push $(git config remote.origin.url) +git push --force-with-lease origin-push + +# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state +git fetch +git tag base master +git rebase -i master +git push --force-with-lease=master:base master:master +``` + +`--force-if-includes` adds a second check on top of bare `--force-with-lease`: it verifies the remote-tracking tip actually appears in your local branch's reflog, i.e. you genuinely integrated it before rewriting. It is a no-op without `--force-with-lease`, and has no effect with the `--force-with-lease=<ref>:<sha>` form, which already pins an exact SHA. + +Safest combination: `git push --force-with-lease --force-if-includes origin`. + +## Server-side policy + +`receive.denyDeletes`, `receive.denyDeleteCurrent` and `receive.denyNonFastForwards` are enforced on the remote regardless of any local flag — a hardened server rejects the push even with `--force`. diff --git a/plugins/git/.apm/skills/git-remotes/references/remote-config.md b/plugins/git/.apm/skills/git-remotes/references/remote-config.md new file mode 100644 index 0000000..5d44eef --- /dev/null +++ b/plugins/git/.apm/skills/git-remotes/references/remote-config.md @@ -0,0 +1,39 @@ +--- +topic: remote-config +source_keys: + - git-scm-remote-docs + - context7-git-htmldocs +--- + +# Remote configuration + +Which remotes exist, where they point, and what they track. + +`git remote show <name>` needs network access — use `-n` for cached data offline, or `git remote -v`, which lists URLs without querying. + +## Add, remove, rename, inspect + +- **Add**: `git remote add <name> <url>`, or `-f` to fetch immediately +- **Remove**: `git remote remove <name>` — deletes the remote, all its tracking refs, and its config +- **Rename**: `git remote rename <old> <new>` +- **Inspect**: `git remote -v` (URLs, offline) or `git remote show <name>` (live tracking status) +- **Effective URLs**: `git remote get-url <name>` shows the URL after `insteadOf` rewrites; `git remote get-url --push --all <name>` lists every push URL + +## Tracking, mirroring, housekeeping + +- **Track one branch**: `git remote add -t <branch> <name> <url>` (repeatable); `--no-tags` suppresses tag import entirely +- **Mirror**: `--mirror=fetch` mirrors all refs locally (bare repos only); `--mirror=push` makes every push behave like `--mirror` +- **Prune stale tracking refs without fetching**: `git remote prune <name>`, with `--dry-run` to preview +- **Default branch pointer**: `git remote set-head <name> -a` (auto-detect, needs a prior fetch), `... <branch>` (explicit), `... -d` (delete `refs/remotes/<name>/HEAD`) + +## `set-url` — full form + +```bash +git remote set-url <name> <newurl> # replace the first fetch URL +git remote set-url <name> <newurl> <oldurl-regex> # replace only the URL matching regex +git remote set-url --push <name> <url> # change push URL only (must point at same repo) +git remote set-url --add <name> <url> # add an extra push URL (push to multiple remotes) +git remote set-url --delete <name> <regex> # remove URLs matching regex +``` + +`--push` changes only where pushes go — fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead; `--push` cannot do this. diff --git a/plugins/git/.apm/skills/git-remotes/references/remotes.md b/plugins/git/.apm/skills/git-remotes/references/remotes.md deleted file mode 100644 index ef765d9..0000000 --- a/plugins/git/.apm/skills/git-remotes/references/remotes.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -topic: remotes -source_keys: - - git-scm-remote-docs - - git-scm-fetch-docs - - git-scm-push-docs - - git-scm-pull-docs ---- - -## `set-url` — full form - -```bash -git remote set-url <name> <newurl> # replace the first fetch URL -git remote set-url <name> <newurl> <oldurl-regex> # replace only the URL matching regex -git remote set-url --push <name> <url> # change push URL only (must point at same repo) -git remote set-url --add <name> <url> # add an extra push URL (push to multiple remotes) -git remote set-url --delete <name> <regex> # remove URLs matching regex -``` - -`--push` changes only where pushes go — fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead. - -## Shallow clones and partial fetch - -```bash -git fetch <remote> <branch> # fetch one branch only, stored in FETCH_HEAD (not a local/tracking ref) -git fetch --depth=<n> # deepen history, or create a shallow clone -git fetch --unshallow # convert a shallow clone to full history -git fetch --update-shallow # allow the fetch to update the shallow boundary -git fetch --refmap='' <remote> <branch> # fetch without updating any tracking ref (FETCH_HEAD only) -``` - -## Default fetch refspec - -The default fetch refspec is `+refs/heads/*:refs/remotes/<name>/*`. The leading `+` forces the update — remote-tracking branches always mirror the remote exactly and provide no protection for local history. Fetch never touches your local branches, only remote-tracking refs. - -## Force-push safety — full detail - -`--force-with-lease` rejects the push if the remote ref moved since your last fetch. Three forms: - -| Form | What it protects | -|---|---| -| `--force-with-lease` (bare) | All refs being pushed, checked against your remote-tracking branch | -| `--force-with-lease=<refname>` | Named ref only | -| `--force-with-lease=<refname>:<sha>` | Named ref must be at exact SHA — most stable | - -**Caveat with the bare form:** any background process that runs `git fetch` (IDE plugin, cron job, editor auto-fetch) updates your remote-tracking branch, which can make the lease check pass even though someone else pushed in between. The protection is silently defeated. - -Two mitigations: - -```bash -# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push -# through a separate remote that nothing else touches, so its tracking ref can't be -# poisoned by an unrelated fetch. -git remote add origin-push $(git config remote.origin.url) -git push --force-with-lease origin-push - -# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state -git fetch -git tag base master -git rebase -i master -git push --force-with-lease=master:base master:master -``` - -`--force-if-includes` adds a second check on top of bare `--force-with-lease`: it verifies the remote-tracking tip actually appears in your local branch's reflog, i.e. you genuinely integrated it before rewriting. It is a no-op without `--force-with-lease`, and has no effect when the `--force-with-lease=<ref>:<sha>` form is used (that form already pins an exact SHA). - -Safest combination: `git push --force-with-lease --force-if-includes origin`. - -Remote-side policies (`receive.denyDeletes`, `receive.denyDeleteCurrent`, `receive.denyNonFastForwards`) are enforced server-side regardless of any local flag — a server configured this way rejects the push even with `--force`. - -## Pull config precedence - -Highest wins: - -1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) -2. `pull.rebase` config (global or local) -3. `branch.<name>.rebase` (branch-specific override) -4. `branch.autoSetupRebase` (set automatically when the tracking branch was created) - -```bash -git config --global pull.rebase true -git config branch.develop.rebase false # develop always merges, regardless of the global default -``` diff --git a/plugins/git/.apm/skills/git-remotes/references/sources.md b/plugins/git/.apm/skills/git-remotes/references/sources.md index 694c756..f2ad70e 100644 --- a/plugins/git/.apm/skills/git-remotes/references/sources.md +++ b/plugins/git/.apm/skills/git-remotes/references/sources.md @@ -12,8 +12,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Remote Management (`git remote`)` **Contributing files:** -- SKILL.md (Remote Management section) -- references/remotes.md (`set-url` full form) +- references/remote-config.md --- @@ -26,8 +25,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Fetching (`git fetch`)` **Contributing files:** -- SKILL.md (Fetch Operations section, Gotchas) -- references/remotes.md (shallow clones, default fetch refspec) +- SKILL.md (Gotchas — prune does not touch tags) +- references/fetch.md --- @@ -40,8 +39,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)` **Contributing files:** -- SKILL.md (Push Operations section, Gotchas) -- references/remotes.md (force-push safety full detail, server-side deny policies) +- SKILL.md (Gotchas — `--force-with-lease` caveat; Step 1 force-push gate) +- references/push.md --- @@ -54,8 +53,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Pulling (`git pull`)` **Contributing files:** -- SKILL.md (Pull Operations section, Gotchas) -- references/remotes.md (pull config precedence) +- SKILL.md (Gotchas — pull default drift) +- references/pull.md (divergence rule; strategies; config precedence) --- @@ -69,3 +68,7 @@ **Contributing files:** - SKILL.md (all sections) +- references/remote-config.md +- references/fetch.md +- references/push.md +- references/pull.md diff --git a/plugins/git/skills/git-remotes/README.md b/plugins/git/skills/git-remotes/README.md index 33382a1..0895aa2 100644 --- a/plugins/git/skills/git-remotes/README.md +++ b/plugins/git/skills/git-remotes/README.md @@ -18,7 +18,17 @@ Describe your remote operation: add a remote, push, pull, fetch, or configure tr | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — force-push gate, dispatch table, return format | | `references/README.md` | Describes the references directory contents | -| `references/remotes.md` | Full `set-url` variants, shallow-clone/fetch options, force-push mitigation detail, and pull config precedence | +| `references/remote-config.md` | Read when adding, removing, renaming, inspecting or re-pointing a remote, or configuring tracking, mirroring, or `set-url` | +| `references/fetch.md` | Read when fetching or pruning remote-tracking refs, or doing a shallow or partial fetch | +| `references/push.md` | Read when pushing branches or tags, writing refspecs, or force-pushing | +| `references/pull.md` | Read when integrating remote changes into the current branch, including the divergence rule | | `references/sources.md` | Research sources and provenance | + +## Composition + +Callers that need submodule initialization after a `--recurse-submodules` pull hand off to +`git-submodules`; local-only work (commits, branches, history) belongs to `git-commits`, +`git-branches`, and `git-history`. The `git-workflow` skill routes humans here for any +remote-touching request. diff --git a/plugins/git/skills/git-remotes/SKILL.md b/plugins/git/skills/git-remotes/SKILL.md index 9163466..5cc0eca 100644 --- a/plugins/git/skills/git-remotes/SKILL.md +++ b/plugins/git/skills/git-remotes/SKILL.md @@ -2,13 +2,11 @@ name: git-remotes description: > - Manage git remote repositories — add/remove/configure remotes, push/pull with safety checks, - handle fetch patterns and tracking branch updates, support multi-remote workflows. - Use when automating remote operations, pushing with force-push safety, fetching with pruning, - pulling with divergence resolution, or managing multi-remote tracking. Include indirect triggers: - any git operation that touches a remote, even if the user doesn't explicitly name the remote. - Do not use when working with local git history, commits, branches, or staging — use git-history - or git-branches instead. + Use when a git operation — remote config, fetch, push, or pull — touches a + remote, even when the user does not name it. + Not local commits -> `git-commits`. + Not local branches -> `git-branches`. + Not submodule pointers -> `git-submodules`. metadata: category: git-workflow @@ -23,91 +21,29 @@ metadata: ## Gotchas -- **Never force-push `main` or `master`, under any circumstances** — this is a hard refusal, not a `confirm: true` gate. If a force-push targets one of these branches, decline and explain why, regardless of how the request is confirmed. -- **Force-push to any other branch requires explicit confirmation** — never execute `git push --force` or `git push -f` without user/agent approval. Always ask or require `confirm: true` flag first. -- **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) that run `git fetch` silently defeat the protection. Always combine with `--force-if-includes` or use explicit SHA form `--force-with-lease=<ref>:<sha>`. -- **Prune doesn't touch tags by default** — `git fetch --prune` leaves orphaned tags. Use `git fetch --prune --prune-tags` or configure `fetch.pruneTags true` globally. -- **Pull with rebase rewrites history** — only safe for unpublished work. Rebasing already-pushed commits breaks everyone downstream. Check what's been pushed before rebasing. -- **`git remote show` requires network access** — use `-n` flag for cached data if working offline. `git remote -v` lists URLs without network queries. -- **Pull behavior defaults shift between Git versions** — older versions default to merge, newer versions to `--ff-only`. Always set `pull.ff only` explicitly for deterministic behavior. +- **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) running `git fetch` silently defeat the protection. Combine it with `--force-if-includes`, or pin the explicit `--force-with-lease=<ref>:<sha>` form. +- **Prune does not touch tags by default** — `git fetch --prune` leaves orphaned tags behind. Use `--prune --prune-tags`, or set `fetch.pruneTags true`. +- **Pull defaults shift between Git versions** — older ones default to merge, newer to `--ff-only`. Set `pull.ff only` explicitly rather than trusting the installed default. -## Operations +## Step 1 — Clear the force-push gate -### Remote Management +`main` and `master` are a hard refusal: decline a force-push targeting either, whatever confirmation accompanies it, because no local approval can restore what the remote loses. On any other branch, `git push --force` and `-f` run only after the caller passes `confirm: true` for that specific push — for a human caller, prompt instead of failing. -Use these to configure which remotes you push to and pull from: +## Step 2 — Dispatch -- **Add a remote**: `git remote add <name> <url>` or `git remote add -f <name> <url>` to fetch immediately -- **Remove a remote**: `git remote remove <name>` (deletes remote + all tracking refs + config) -- **Rename a remote**: `git remote rename <old> <new>` -- **Inspect remotes**: `git remote -v` (show URLs) or `git remote show <name>` (live tracking status, requires network) -- **Set-url separately for fetch vs. push**: `git remote set-url --push <name> <url>` changes only where pushes go — but fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead; `--push` cannot do this. Full `set-url` variants (regex-targeted replace, `--add`, `--delete`): `references/remotes.md`. -- **Remove a stale URL**: `git remote set-url --delete <name> <regex>` -- **Inspect effective URLs**: `git remote get-url <name>` (shows URL after `insteadOf` rewrites) or `git remote get-url --push --all <name>` (all push URLs) -- **Track only one branch**: `git remote add -t <branch> <name> <url>` (repeatable), or suppress tag import entirely with `git remote add --no-tags <name> <url>` -- **Mirror a remote**: `git remote add --mirror=fetch <name> <url>` mirrors all refs locally (bare repos only); `--mirror=push` makes every push behave like `--mirror` -- **Prune stale tracking refs without fetching**: `git remote prune <name>` (add `--dry-run` to preview first) -- **Set the remote's default branch pointer**: `git remote set-head <name> -a` (auto-detect, requires a prior fetch), `git remote set-head <name> <branch>` (explicit), or `git remote set-head <name> -d` (delete `refs/remotes/<name>/HEAD`) +Read the row matching the operation, and only that row — each file is self-contained. A task spanning two operations reads both. -### Fetch Operations - -Use these to update your tracking branches without touching your local branches: - -- **Fetch from one remote**: `git fetch <remote>` — fetches all branches -- **Fetch one branch only**: `git fetch <remote> <branch>` — stores the result in `FETCH_HEAD`, not a tracking ref -- **Fetch from all remotes**: `git fetch --all` with optional `--prune` to clean up stale tracking refs -- **Prune properly**: Use `git fetch --all --prune --prune-tags` to clean both branches and tags -- **Configure auto-prune**: Set `git config --global fetch.prune true` to auto-prune on every fetch across all remotes (or `remote.<name>.prune` to scope it to one remote) -- **Shallow clones**: `--depth=<n>` to deepen or create a shallow clone, `--unshallow` to convert to full history, `--update-shallow` to allow the shallow boundary to move. Details and the default fetch refspec: `references/remotes.md`. - -Fetch never modifies your local branches — it only updates remote-tracking branches (`refs/remotes/origin/*`). - -### Push Operations - -Use these to send your commits upstream. Default: safe push to same-named branch on the remote. - -- **Basic push**: `git push <remote> <branch>` — pushes to same-named remote branch -- **Set upstream**: `git push -u <remote> <branch>` — push and configure this branch to track the remote -- **Multi-remote push**: `git push origin develop` and `git push staging develop` sequentially, or use `git remote set-url --add <name> <url>` to push to multiple remotes with one command -- **Force-push safety**: Always use `git push --force-with-lease --force-if-includes <remote> <branch>` over bare `--force`. Require explicit confirmation first — and never for `main`/`master` (see Gotchas). `--force-if-includes` is a no-op without `--force-with-lease`. If background tools (IDE, cron) auto-fetch and could poison the lease check, use a dedicated push-only remote instead — see `references/remotes.md`. -- **Server-side enforcement**: `receive.denyDeletes`, `receive.denyDeleteCurrent`, and `receive.denyNonFastForwards` are enforced on the remote regardless of local flags — a hardened server rejects the push even with `--force`. -- **Delete remote branch**: `git push <remote> --delete <branch>` (not `:<branch>` syntax; clearer and cleaner) -- **Push everything**: `git push --all` (all local branches) or `git push --tags` (all tags) -- **Push a single tag**: `git push origin <tag>` -- **Delete remote branches with no local counterpart**: `git push --prune origin 'refs/heads/*:refs/heads/*'` -- **Force only part of a multi-ref push**: prefix the one refspec that needs it with `+`, e.g. `git push origin +main develop` forces `main` while safe-pushing `develop` - -Refspec syntax is `[+]<src>[:<dst>]`: - -| Pattern | Meaning | +| Operation | Read | |---|---| -| `<branch>` | Push to same-named remote branch | -| `<src>:<dst>` | Push `<src>` local ref to `<dst>` remote ref | -| `+<src>:<dst>` | Force this refspec (non-fast-forward allowed) | -| `:<branch>` | Delete remote `<branch>` | -| `refs/heads/*:refs/heads/*` | Glob: push all matching branches | -| `^refs/heads/dev-*` | Negative: exclude matching refs | -| `tag <name>` | Sugar for `refs/tags/<name>:refs/tags/<name>` | +| Add, remove, rename, inspect, or re-point a remote; tracking, mirror, and `set-url` config | `references/remote-config.md` | +| Fetch or prune remote-tracking refs; shallow or partial fetch | `references/fetch.md` | +| Push branches or tags; refspecs; force-push | `references/push.md` | +| Pull — integrate remote changes into the current branch | `references/pull.md` | -### Pull Operations +## Step 3 — Return format -Use these to fetch and integrate remote changes. Default strategy: `--ff-only` (fail if diverged, forcing a conscious choice). +For agent callers, return: -- **Pull with fast-forward only**: `git pull --ff-only` (recommended default — fails if you've diverged, forcing a rebase/merge decision) -- **Pull with rebase**: `git pull --rebase` (replays your unpublished commits on top; linear history, but rewrites SHAs — only safe for unpublished work) -- **Pull with merge**: `git pull --no-rebase` (three-way merge commit; preserves original commits, non-linear) -- **Pull with rebase, preserving merges**: `git pull --rebase=merges` (like `--rebase`, but keeps intentional local merge commits during replay) -- **Pull without integrating**: `git pull --squash` collapses incoming commits into staged changes without committing — you write the commit message -- **Set pull strategy globally**: `git config pull.ff only` (or `pull.rebase true`; respects branch-specific overrides via `branch.<name>.rebase`). Full precedence order (CLI flag > `pull.rebase` > `branch.<name>.rebase` > `branch.autoSetupRebase`): `references/remotes.md`. -- **Check before rebasing**: Always verify your commits haven't been pushed before using `--rebase`. Rebasing published commits breaks everyone downstream. -- **Merge strategy default**: Git 2.34+ defaults to the `ort` merge strategy (`recursive` is now just an alias for it). Strategy options like `-X ours`, `-X theirs`, `-X ignore-space-change` still pass through unchanged. -- **Submodules on pull**: `--recurse-submodules` only fetches submodules that are already checked out — newly added submodules are not initialized automatically. Use the `git-submodules` skill to initialize new ones. - -If pull diverges and you haven't set a strategy, the operation fails — this is good, forces a conscious choice. Never auto-merge diverged branches without asking. - -### Return Format (for agents) - -Return structured output: ```json { "success": true, @@ -116,8 +52,8 @@ Return structured output: "branch": "main", "output": "...", "warnings": ["force-with-lease not confirmed"], - "recommendations": ["set pull.ff=only globally"] + "recommendations": ["set `pull.ff only` so the default does not vary by Git version"] } ``` -On failure, include `error` field with root cause and recovery suggestion. +On failure, set `success: false` and add an `error` field holding the root cause and a recovery suggestion. diff --git a/plugins/git/skills/git-remotes/references/README.md b/plugins/git/skills/git-remotes/references/README.md index ea38459..a9ca455 100644 --- a/plugins/git/skills/git-remotes/references/README.md +++ b/plugins/git/skills/git-remotes/references/README.md @@ -14,4 +14,7 @@ This directory contains provenance metadata and research sources for the `git-re ## Files - `sources.md` — Extracted research sources and their contributing documents -- `remotes.md` — Full `set-url` variants, shallow-clone/fetch options, default fetch refspec, force-push mitigation detail, server-side deny policies, and pull config precedence +- `remote-config.md` — Remote add/remove/rename/inspect, tracking and mirror options, housekeeping, and the full `set-url` form +- `fetch.md` — Fetch and prune options, shallow and partial fetch, the default fetch refspec +- `push.md` — Push options, refspec syntax, force-push safety in full, server-side deny policies +- `pull.md` — Pull strategies, submodule caveat, the divergence rule, and pull config precedence diff --git a/plugins/git/skills/git-remotes/references/fetch.md b/plugins/git/skills/git-remotes/references/fetch.md new file mode 100644 index 0000000..e0c05ff --- /dev/null +++ b/plugins/git/skills/git-remotes/references/fetch.md @@ -0,0 +1,29 @@ +--- +topic: fetch +source_keys: + - git-scm-fetch-docs + - context7-git-htmldocs +--- + +# Fetching + +Fetch updates remote-tracking branches (`refs/remotes/<name>/*`) and never modifies a local branch, so it is always safe to run. + +- **One remote**: `git fetch <remote>` — all branches +- **One branch**: `git fetch <remote> <branch>` — the result lands in `FETCH_HEAD`, not a tracking ref +- **All remotes**: `git fetch --all` +- **Prune properly**: `git fetch --all --prune --prune-tags` cleans stale branches *and* tags +- **Auto-prune**: `git config --global fetch.prune true` (or `remote.<name>.prune` to scope it to one remote), and `fetch.pruneTags true` for tags + +## Shallow and partial fetch + +```bash +git fetch --depth=<n> # deepen history, or create a shallow clone +git fetch --unshallow # convert a shallow clone to full history +git fetch --update-shallow # allow the fetch to update the shallow boundary +git fetch --refmap='' <remote> <branch> # fetch without updating any tracking ref (FETCH_HEAD only) +``` + +## Default fetch refspec + +The default is `+refs/heads/*:refs/remotes/<name>/*`. The leading `+` forces the update — remote-tracking branches always mirror the remote exactly and offer no protection for local history. diff --git a/plugins/git/skills/git-remotes/references/pull.md b/plugins/git/skills/git-remotes/references/pull.md new file mode 100644 index 0000000..3a8a25a --- /dev/null +++ b/plugins/git/skills/git-remotes/references/pull.md @@ -0,0 +1,37 @@ +--- +topic: pull +source_keys: + - git-scm-pull-docs + - context7-git-htmldocs +--- + +# Pulling + +Default strategy: `--ff-only`. It fails on divergence, which forces a conscious choice instead of an accidental merge commit. + +- **Fast-forward only**: `git pull --ff-only` — the recommended default +- **Rebase**: `git pull --rebase` replays your commits on top for linear history, but rewrites SHAs. Verify nothing being replayed has been pushed: rebasing published commits breaks everyone downstream. +- **Merge**: `git pull --no-rebase` — three-way merge commit, preserves original commits, non-linear +- **Rebase preserving merges**: `git pull --rebase=merges` keeps intentional local merge commits during the replay +- **Stage without committing**: `git pull --squash` collapses incoming commits into staged changes; you write the message +- **Merge strategy**: Git 2.34+ defaults to `ort` (`recursive` is now an alias for it). Strategy options such as `-X ours`, `-X theirs`, `-X ignore-space-change` pass through unchanged. +- **Submodules**: `--recurse-submodules` only fetches submodules already checked out. Newly added ones are not initialized — use the `git-submodules` skill for those. + +## On divergence + +A pull that diverges with no strategy configured fails, and that failure is the useful outcome. Report the divergence and the three ways out — `--ff-only`, `--rebase`, `--no-rebase` — and let the caller choose. Auto-merging a diverged branch buries a decision that belongs to the human. + +## Config precedence + +Highest wins: + +1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) +2. `pull.rebase` config (global or local) +3. `branch.<name>.rebase` (branch-specific override) +4. `branch.autoSetupRebase` (set automatically when the tracking branch was created) + +```bash +git config pull.ff only # deterministic default across Git versions +git config --global pull.rebase true +git config branch.develop.rebase false # develop always merges, regardless of the global default +``` diff --git a/plugins/git/skills/git-remotes/references/push.md b/plugins/git/skills/git-remotes/references/push.md new file mode 100644 index 0000000..dd032fb --- /dev/null +++ b/plugins/git/skills/git-remotes/references/push.md @@ -0,0 +1,67 @@ +--- +topic: push +source_keys: + - git-scm-push-docs + - context7-git-htmldocs +--- + +# Pushing + +Default: safe push to the same-named branch on the remote. + +- **Force-push**: never bare `--force`. Use `git push --force-with-lease --force-if-includes <remote> <branch>`, after the SKILL.md Step 1 gate. +- **Basic**: `git push <remote> <branch>` +- **Set upstream**: `git push -u <remote> <branch>` — push and configure tracking +- **Multi-remote**: push sequentially (`git push origin develop`, `git push staging develop`), or add a second push URL with `git remote set-url --add <name> <url>` to reach both in one command +- **Delete a remote branch**: `git push <remote> --delete <branch>` — clearer than the `:<branch>` form +- **Bulk**: `git push --all` (all local branches), `git push --tags` (all tags), `git push origin <tag>` (one tag) +- **Delete remote branches with no local counterpart**: `git push --prune origin 'refs/heads/*:refs/heads/*'` +- **Force only part of a multi-ref push**: prefix the one refspec that needs it with `+` — `git push origin +release develop` forces `release` while safe-pushing `develop`. A `+` prefix is a force-push and passes the SKILL.md Step 1 gate like any other. + +## Refspec syntax — `[+]<src>[:<dst>]` + +| Pattern | Meaning | +|---|---| +| `<branch>` | Push to same-named remote branch | +| `<src>:<dst>` | Push `<src>` local ref to `<dst>` remote ref | +| `+<src>:<dst>` | Force this refspec (non-fast-forward allowed) — a force-push; passes the SKILL.md Step 1 gate | +| `:<branch>` | Delete remote `<branch>` | +| `refs/heads/*:refs/heads/*` | Glob: push all matching branches | +| `^refs/heads/dev-*` | Negative: exclude matching refs | +| `tag <name>` | Sugar for `refs/tags/<name>:refs/tags/<name>` | + +## Force-push safety — full detail + +`--force-with-lease` rejects the push if the remote ref moved since your last fetch. Three forms: + +| Form | What it protects | +|---|---| +| `--force-with-lease` (bare) | All refs being pushed, checked against your remote-tracking branch | +| `--force-with-lease=<refname>` | Named ref only | +| `--force-with-lease=<refname>:<sha>` | Named ref must be at exact SHA — most stable | + +**Caveat with the bare form:** any background process that runs `git fetch` (IDE plugin, cron job, editor auto-fetch) updates your remote-tracking branch, which can make the lease check pass even though someone else pushed in between. The protection is silently defeated. + +Two mitigations: + +```bash +# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push +# through a separate remote that nothing else touches, so its tracking ref can't be +# poisoned by an unrelated fetch. +git remote add origin-push $(git config remote.origin.url) +git push --force-with-lease origin-push + +# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state +git fetch +git tag base master +git rebase -i master +git push --force-with-lease=master:base master:master +``` + +`--force-if-includes` adds a second check on top of bare `--force-with-lease`: it verifies the remote-tracking tip actually appears in your local branch's reflog, i.e. you genuinely integrated it before rewriting. It is a no-op without `--force-with-lease`, and has no effect with the `--force-with-lease=<ref>:<sha>` form, which already pins an exact SHA. + +Safest combination: `git push --force-with-lease --force-if-includes origin`. + +## Server-side policy + +`receive.denyDeletes`, `receive.denyDeleteCurrent` and `receive.denyNonFastForwards` are enforced on the remote regardless of any local flag — a hardened server rejects the push even with `--force`. diff --git a/plugins/git/skills/git-remotes/references/remote-config.md b/plugins/git/skills/git-remotes/references/remote-config.md new file mode 100644 index 0000000..5d44eef --- /dev/null +++ b/plugins/git/skills/git-remotes/references/remote-config.md @@ -0,0 +1,39 @@ +--- +topic: remote-config +source_keys: + - git-scm-remote-docs + - context7-git-htmldocs +--- + +# Remote configuration + +Which remotes exist, where they point, and what they track. + +`git remote show <name>` needs network access — use `-n` for cached data offline, or `git remote -v`, which lists URLs without querying. + +## Add, remove, rename, inspect + +- **Add**: `git remote add <name> <url>`, or `-f` to fetch immediately +- **Remove**: `git remote remove <name>` — deletes the remote, all its tracking refs, and its config +- **Rename**: `git remote rename <old> <new>` +- **Inspect**: `git remote -v` (URLs, offline) or `git remote show <name>` (live tracking status) +- **Effective URLs**: `git remote get-url <name>` shows the URL after `insteadOf` rewrites; `git remote get-url --push --all <name>` lists every push URL + +## Tracking, mirroring, housekeeping + +- **Track one branch**: `git remote add -t <branch> <name> <url>` (repeatable); `--no-tags` suppresses tag import entirely +- **Mirror**: `--mirror=fetch` mirrors all refs locally (bare repos only); `--mirror=push` makes every push behave like `--mirror` +- **Prune stale tracking refs without fetching**: `git remote prune <name>`, with `--dry-run` to preview +- **Default branch pointer**: `git remote set-head <name> -a` (auto-detect, needs a prior fetch), `... <branch>` (explicit), `... -d` (delete `refs/remotes/<name>/HEAD`) + +## `set-url` — full form + +```bash +git remote set-url <name> <newurl> # replace the first fetch URL +git remote set-url <name> <newurl> <oldurl-regex> # replace only the URL matching regex +git remote set-url --push <name> <url> # change push URL only (must point at same repo) +git remote set-url --add <name> <url> # add an extra push URL (push to multiple remotes) +git remote set-url --delete <name> <regex> # remove URLs matching regex +``` + +`--push` changes only where pushes go — fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead; `--push` cannot do this. diff --git a/plugins/git/skills/git-remotes/references/remotes.md b/plugins/git/skills/git-remotes/references/remotes.md deleted file mode 100644 index ef765d9..0000000 --- a/plugins/git/skills/git-remotes/references/remotes.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -topic: remotes -source_keys: - - git-scm-remote-docs - - git-scm-fetch-docs - - git-scm-push-docs - - git-scm-pull-docs ---- - -## `set-url` — full form - -```bash -git remote set-url <name> <newurl> # replace the first fetch URL -git remote set-url <name> <newurl> <oldurl-regex> # replace only the URL matching regex -git remote set-url --push <name> <url> # change push URL only (must point at same repo) -git remote set-url --add <name> <url> # add an extra push URL (push to multiple remotes) -git remote set-url --delete <name> <regex> # remove URLs matching regex -``` - -`--push` changes only where pushes go — fetch and push URLs must still reference the same repository. For genuine fetch-from-A / push-to-B workflows, use two separate named remotes instead. - -## Shallow clones and partial fetch - -```bash -git fetch <remote> <branch> # fetch one branch only, stored in FETCH_HEAD (not a local/tracking ref) -git fetch --depth=<n> # deepen history, or create a shallow clone -git fetch --unshallow # convert a shallow clone to full history -git fetch --update-shallow # allow the fetch to update the shallow boundary -git fetch --refmap='' <remote> <branch> # fetch without updating any tracking ref (FETCH_HEAD only) -``` - -## Default fetch refspec - -The default fetch refspec is `+refs/heads/*:refs/remotes/<name>/*`. The leading `+` forces the update — remote-tracking branches always mirror the remote exactly and provide no protection for local history. Fetch never touches your local branches, only remote-tracking refs. - -## Force-push safety — full detail - -`--force-with-lease` rejects the push if the remote ref moved since your last fetch. Three forms: - -| Form | What it protects | -|---|---| -| `--force-with-lease` (bare) | All refs being pushed, checked against your remote-tracking branch | -| `--force-with-lease=<refname>` | Named ref only | -| `--force-with-lease=<refname>:<sha>` | Named ref must be at exact SHA — most stable | - -**Caveat with the bare form:** any background process that runs `git fetch` (IDE plugin, cron job, editor auto-fetch) updates your remote-tracking branch, which can make the lease check pass even though someone else pushed in between. The protection is silently defeated. - -Two mitigations: - -```bash -# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push -# through a separate remote that nothing else touches, so its tracking ref can't be -# poisoned by an unrelated fetch. -git remote add origin-push $(git config remote.origin.url) -git push --force-with-lease origin-push - -# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state -git fetch -git tag base master -git rebase -i master -git push --force-with-lease=master:base master:master -``` - -`--force-if-includes` adds a second check on top of bare `--force-with-lease`: it verifies the remote-tracking tip actually appears in your local branch's reflog, i.e. you genuinely integrated it before rewriting. It is a no-op without `--force-with-lease`, and has no effect when the `--force-with-lease=<ref>:<sha>` form is used (that form already pins an exact SHA). - -Safest combination: `git push --force-with-lease --force-if-includes origin`. - -Remote-side policies (`receive.denyDeletes`, `receive.denyDeleteCurrent`, `receive.denyNonFastForwards`) are enforced server-side regardless of any local flag — a server configured this way rejects the push even with `--force`. - -## Pull config precedence - -Highest wins: - -1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) -2. `pull.rebase` config (global or local) -3. `branch.<name>.rebase` (branch-specific override) -4. `branch.autoSetupRebase` (set automatically when the tracking branch was created) - -```bash -git config --global pull.rebase true -git config branch.develop.rebase false # develop always merges, regardless of the global default -``` diff --git a/plugins/git/skills/git-remotes/references/sources.md b/plugins/git/skills/git-remotes/references/sources.md index 694c756..f2ad70e 100644 --- a/plugins/git/skills/git-remotes/references/sources.md +++ b/plugins/git/skills/git-remotes/references/sources.md @@ -12,8 +12,7 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Remote Management (`git remote`)` **Contributing files:** -- SKILL.md (Remote Management section) -- references/remotes.md (`set-url` full form) +- references/remote-config.md --- @@ -26,8 +25,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Fetching (`git fetch`)` **Contributing files:** -- SKILL.md (Fetch Operations section, Gotchas) -- references/remotes.md (shallow clones, default fetch refspec) +- SKILL.md (Gotchas — prune does not touch tags) +- references/fetch.md --- @@ -40,8 +39,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Pushing (`git push`)` **Contributing files:** -- SKILL.md (Push Operations section, Gotchas) -- references/remotes.md (force-push safety full detail, server-side deny policies) +- SKILL.md (Gotchas — `--force-with-lease` caveat; Step 1 force-push gate) +- references/push.md --- @@ -54,8 +53,8 @@ - **Research doc:** plugins/git/docs/research/docs/git/remotes.md → `## Pulling (`git pull`)` **Contributing files:** -- SKILL.md (Pull Operations section, Gotchas) -- references/remotes.md (pull config precedence) +- SKILL.md (Gotchas — pull default drift) +- references/pull.md (divergence rule; strategies; config precedence) --- @@ -69,3 +68,7 @@ **Contributing files:** - SKILL.md (all sections) +- references/remote-config.md +- references/fetch.md +- references/push.md +- references/pull.md -- 2.43.0 From 3c74beb280b5c866d5e70871878ba9c68a45c83b Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 18/89] refactor(git-workflow): retrofit to the ADR-0020 context contract Description 566 -> 249 chars, body 644 -> 398 words, Gotchas 12 -> 2. The eight organisational hard rules move to references/hard-rules.md. Its load trigger enumerates operations rather than rule topics: the first draft keyed on 'commit message form', which left the atomicity and working-state rules unreachable when a caller supplied a conventional message. Also promotes the destructive-op confirmation ahead of the orchestrator invocation, which it previously followed. --- .../git/.apm/skills/git-workflow/README.md | 3 +- plugins/git/.apm/skills/git-workflow/SKILL.md | 77 +++++++++---------- .../skills/git-workflow/references/README.md | 5 +- .../git-workflow/references/hard-rules.md | 25 ++++++ .../skills/git-workflow/references/sources.md | 3 +- plugins/git/skills/git-workflow/README.md | 3 +- plugins/git/skills/git-workflow/SKILL.md | 77 +++++++++---------- .../skills/git-workflow/references/README.md | 5 +- .../git-workflow/references/hard-rules.md | 25 ++++++ .../skills/git-workflow/references/sources.md | 3 +- 10 files changed, 136 insertions(+), 90 deletions(-) create mode 100644 plugins/git/.apm/skills/git-workflow/references/hard-rules.md create mode 100644 plugins/git/skills/git-workflow/references/hard-rules.md diff --git a/plugins/git/.apm/skills/git-workflow/README.md b/plugins/git/.apm/skills/git-workflow/README.md index c401e63..607924f 100644 --- a/plugins/git/.apm/skills/git-workflow/README.md +++ b/plugins/git/.apm/skills/git-workflow/README.md @@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp ## What it does -This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. +This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. ## Usage @@ -20,5 +20,6 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma |------|---------| | `SKILL.md` | Skill instructions for agents | | `README.md` | This file | +| `references/hard-rules.md` | The org's non-negotiable git rules; read when a request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials | | `references/README.md` | Describes the references directory contents | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/.apm/skills/git-workflow/SKILL.md b/plugins/git/.apm/skills/git-workflow/SKILL.md index 197bf49..5dd21a8 100644 --- a/plugins/git/.apm/skills/git-workflow/SKILL.md +++ b/plugins/git/.apm/skills/git-workflow/SKILL.md @@ -2,12 +2,9 @@ name: git-workflow description: > - Use when a human user wants to perform git workflows interactively — commits, branch management, - history inspection, submodules, worktrees, or remotes. Provides a friendly, conversational - interface with clarification prompts ("Which branch base?"), progress updates, inline help, - best practices guidance, and confirmation dialogs for destructive operations. Guides users - through complex git patterns even if they don't mention every detail. Do not use when the - caller is an agent—agents should invoke git-orchestrate directly for deterministic, composable execution. + Use when a human wants to work through local git interactively — commits, branches, history, + submodules, worktrees, or remotes. Not an agent caller needing deterministic execution -> + `git-orchestrate`. Not server-side Gitea work -> `gitea-workflow`. metadata: category: git @@ -21,45 +18,41 @@ metadata: ## Gotchas -- This skill is specifically for **human interaction**. If the caller is an agent, invoke `git-orchestrate` directly instead—this skill adds UI overhead agents don't need. -- Session context from previous git operations (branch names, commit strategy) persists during a single multi-step user request, then clears. Users don't need to re-provide decisions within one workflow. -- Destructive operations require explicit confirmation: force-push, branch deletion, rebase with history loss, force-checkout. Users must confirm interactively; the skill never proceeds without their approval on destructive ops. -- Run git commands through `rtk git <command>` rather than bare `git <command>` for parent-repo operations — this is a mandated org wrapper, not an optional style choice. Drop into a submodule's own directory for submodule-specific commands (see `git-submodules`). - -### Hard rules - -These are non-negotiable regardless of what the user asks for — surface them proactively rather than waiting for the user to hit them (`org-git-conventions`; sub-skills invoked directly by humans, like this one, carry their own local copy of these rules for readers who won't chain through `git-orchestrate`, so state them plainly rather than assuming the user already knows them): - -- Never skip hooks with `--no-verify` — hooks are the automated QA gate, and bypassing them breaks the pipeline for everyone downstream. -- Never force-push `main` or `master`. -- Keep commits atomic — each commit should represent one logical, independently reviewable and reversible change. -- Every commit must leave the repository in a working state (buildable/testable where practical). -- Commit messages explain **why**, not **what** — the diff already documents what changed. -- Never commit secrets, credentials, or environment-specific config. -- Use Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`, etc.). -- Reference related issues, ADRs, or design documents using Git trailers when applicable. - -If a user's request conflicts with a hard rule (e.g. "force-push main to fix this"), explain the rule and propose a safe alternative instead of complying. +- Session context built during one multi-step request — branch names, the chosen base, the commit + strategy — persists for that request and then clears. Do not re-ask the user for a decision they + already gave you earlier in the same workflow. +- Run parent-repo commands through `rtk git <command>`, never bare `git <command>`. This is a + mandated org wrapper, not a style preference. Submodule-specific commands run from inside the + submodule's own directory instead. ## Workflow -When a user wants to perform git workflows: - -1. **Parse the user's intent** — extract the high-level task (commit, create branch, rebase, inspect history, etc.) and any explicit options they mentioned. -2. **Build session context** — gather repo state, current branch, any prior decisions in this workflow (branch intent for commit messages, base branch for rebasing, etc.). -3. **Invoke git-orchestrate agent** — call it with: - - `operation`: the git operation (e.g., "commit", "create-branch", "rebase") - - `parameters`: user-provided or inferred options - - `context`: decisions and repo state from prior steps in this workflow - - `confirm`: `true` if a destructive op and the user confirmed, otherwise omit -4. **Handle the response** — if orchestrator succeeds, present results in plain language with progress updates and explanations. If it fails, show the error reason and suggest recovery actions. -5. **Clarification prompts** — if the orchestrator needs more information (e.g., "Which branch should this be based on?"), prompt the user conversationally and loop back with the user's input. -6. **Confirmation gates** — before executing any destructive op (force-push, branch deletion, rebase, force-checkout), show what will happen and ask "Proceed?" If the user declines, cancel gracefully. +1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) + and any options the user named. +2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or + touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule + before acting, not after. +3. **Read the repo** — current branch, working-tree state, and which branching model the repo + follows (the orchestrator reads `branching_pattern` from plugin config; infer from branch names + if absent); the last of those decides which tips are worth offering. +4. **Gate destructive operations** — before force-push, branch deletion, rebase, or + force-checkout, show what will happen and ask "Proceed?". Cancel gracefully if the user + declines. Never supply the confirmation on the user's behalf. Some operations are refusals, not + confirmations: never offer "Proceed?" for a force-push of `main` or `master`. +5. **Invoke the `git-orchestrate` agent** with `operation`, `parameters` (user-provided or + inferred), `context` (step 3 plus the session context), and `confirm: true` only for a + destructive op the user approved in step 4. +6. **Clarify when the orchestrator asks for more** — put its question to the user in plain + language ("Which branch should this be based on?") and loop back to step 5 with the answer. +7. **Report the outcome** — on success, the result and what changed, in plain language; on + failure, the error reason and a recovery action. ## Interaction style -- **Conversational**: Use natural language, not technical jargon. "Let me rebase your changes onto main" not "Running git rebase --interactive main". -- **Pedagogical**: Explain what each step does and why. "I'm squashing your last 3 commits into one clean commit" not just "Squashing commits". -- **Guided**: Offer inline help. When users mention ambiguous steps, suggest best practices. Match the tip to the repo's branching model: for Gitflow-style repos, "Tip: Feature branches branch off `develop`, not `main` — `main` only tracks released code." For trunk-based/GitHub Flow repos, "Tip: Short-lived feature branches off `main` keep merges small and reviewable." -- **Transparent**: Show progress. "Creating branch feature/user-auth..." then "✓ Branch created. Ready to commit." Humans benefit from seeing workflow state. -- **Safe**: Always confirm before destructive ops. Never silently rewrite history or force-push without explicit user approval. +The caller is a human, so the interaction is the point. Explain each step and why it happens ("I'm +squashing your last 3 commits into one clean commit" beats "Squashing commits"), show progress as +you go, and prefer natural language to raw command lines. + +Match tips to the repo's branching model rather than offering generic advice: on a Gitflow repo, +feature branches come off `develop` and `main` tracks only released code; on a trunk-based or +GitHub Flow repo, short-lived branches off `main` keep merges small and reviewable. diff --git a/plugins/git/.apm/skills/git-workflow/references/README.md b/plugins/git/.apm/skills/git-workflow/references/README.md index 37edf15..bcf2db0 100644 --- a/plugins/git/.apm/skills/git-workflow/references/README.md +++ b/plugins/git/.apm/skills/git-workflow/references/README.md @@ -9,8 +9,11 @@ source_keys: # References -This directory contains provenance metadata and research sources for the `git-workflow` skill. +This directory contains the org git rules and the provenance metadata for the `git-workflow` +skill. ## Files +- `hard-rules.md` — The org's non-negotiable git rules, loaded when a request creates, amends, or + rewrites a commit, pushes, or touches hooks, config, or credentials - `sources.md` — Extracted research sources and their contributing documents diff --git a/plugins/git/.apm/skills/git-workflow/references/hard-rules.md b/plugins/git/.apm/skills/git-workflow/references/hard-rules.md new file mode 100644 index 0000000..fd2262d --- /dev/null +++ b/plugins/git/.apm/skills/git-workflow/references/hard-rules.md @@ -0,0 +1,25 @@ +--- +source_keys: + - org-git-conventions +--- + +# Org git hard rules + +Non-negotiable regardless of what the user asks for. Surface the relevant one proactively rather +than waiting for the user to hit it — a human invoking this skill directly never sees the +orchestrator's copy of these rules, so raise them here. + +- Never skip hooks with `--no-verify` — hooks are the automated QA gate, and bypassing them breaks + the pipeline for everyone downstream. +- Never force-push `main` or `master`. +- Keep commits atomic — each commit should represent one logical, independently reviewable and + reversible change. +- Every commit must leave the repository in a working state (buildable/testable where practical). +- Commit messages explain **why**, not **what** — the diff already documents what changed. +- Never commit secrets, credentials, or environment-specific config. +- Use Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`, etc.). +- Reference related issues, ADRs, or design documents using Git trailers when applicable. + +If a user's request conflicts with one of these (e.g. "force-push main to fix this"), explain the +rule and propose a safe alternative instead of complying. Do not comply and note the rule +afterwards. diff --git a/plugins/git/.apm/skills/git-workflow/references/sources.md b/plugins/git/.apm/skills/git-workflow/references/sources.md index b5e6c4a..1d8177c 100644 --- a/plugins/git/.apm/skills/git-workflow/references/sources.md +++ b/plugins/git/.apm/skills/git-workflow/references/sources.md @@ -56,4 +56,5 @@ - **Research doc:** none — org convention, not part of the plugin's research corpus (no `plugins/git/docs/research/` topic file backs this entry) **Contributing files:** -- SKILL.md (Gotchas — Hard rules subsection, rtk git note) +- references/hard-rules.md (whole file — the eight hard rules and the conflict-handling rule) +- SKILL.md (Gotchas — `rtk git` wrapper note) diff --git a/plugins/git/skills/git-workflow/README.md b/plugins/git/skills/git-workflow/README.md index c401e63..607924f 100644 --- a/plugins/git/skills/git-workflow/README.md +++ b/plugins/git/skills/git-workflow/README.md @@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp ## What it does -This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. +This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. ## Usage @@ -20,5 +20,6 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma |------|---------| | `SKILL.md` | Skill instructions for agents | | `README.md` | This file | +| `references/hard-rules.md` | The org's non-negotiable git rules; read when a request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials | | `references/README.md` | Describes the references directory contents | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/skills/git-workflow/SKILL.md b/plugins/git/skills/git-workflow/SKILL.md index 197bf49..5dd21a8 100644 --- a/plugins/git/skills/git-workflow/SKILL.md +++ b/plugins/git/skills/git-workflow/SKILL.md @@ -2,12 +2,9 @@ name: git-workflow description: > - Use when a human user wants to perform git workflows interactively — commits, branch management, - history inspection, submodules, worktrees, or remotes. Provides a friendly, conversational - interface with clarification prompts ("Which branch base?"), progress updates, inline help, - best practices guidance, and confirmation dialogs for destructive operations. Guides users - through complex git patterns even if they don't mention every detail. Do not use when the - caller is an agent—agents should invoke git-orchestrate directly for deterministic, composable execution. + Use when a human wants to work through local git interactively — commits, branches, history, + submodules, worktrees, or remotes. Not an agent caller needing deterministic execution -> + `git-orchestrate`. Not server-side Gitea work -> `gitea-workflow`. metadata: category: git @@ -21,45 +18,41 @@ metadata: ## Gotchas -- This skill is specifically for **human interaction**. If the caller is an agent, invoke `git-orchestrate` directly instead—this skill adds UI overhead agents don't need. -- Session context from previous git operations (branch names, commit strategy) persists during a single multi-step user request, then clears. Users don't need to re-provide decisions within one workflow. -- Destructive operations require explicit confirmation: force-push, branch deletion, rebase with history loss, force-checkout. Users must confirm interactively; the skill never proceeds without their approval on destructive ops. -- Run git commands through `rtk git <command>` rather than bare `git <command>` for parent-repo operations — this is a mandated org wrapper, not an optional style choice. Drop into a submodule's own directory for submodule-specific commands (see `git-submodules`). - -### Hard rules - -These are non-negotiable regardless of what the user asks for — surface them proactively rather than waiting for the user to hit them (`org-git-conventions`; sub-skills invoked directly by humans, like this one, carry their own local copy of these rules for readers who won't chain through `git-orchestrate`, so state them plainly rather than assuming the user already knows them): - -- Never skip hooks with `--no-verify` — hooks are the automated QA gate, and bypassing them breaks the pipeline for everyone downstream. -- Never force-push `main` or `master`. -- Keep commits atomic — each commit should represent one logical, independently reviewable and reversible change. -- Every commit must leave the repository in a working state (buildable/testable where practical). -- Commit messages explain **why**, not **what** — the diff already documents what changed. -- Never commit secrets, credentials, or environment-specific config. -- Use Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`, etc.). -- Reference related issues, ADRs, or design documents using Git trailers when applicable. - -If a user's request conflicts with a hard rule (e.g. "force-push main to fix this"), explain the rule and propose a safe alternative instead of complying. +- Session context built during one multi-step request — branch names, the chosen base, the commit + strategy — persists for that request and then clears. Do not re-ask the user for a decision they + already gave you earlier in the same workflow. +- Run parent-repo commands through `rtk git <command>`, never bare `git <command>`. This is a + mandated org wrapper, not a style preference. Submodule-specific commands run from inside the + submodule's own directory instead. ## Workflow -When a user wants to perform git workflows: - -1. **Parse the user's intent** — extract the high-level task (commit, create branch, rebase, inspect history, etc.) and any explicit options they mentioned. -2. **Build session context** — gather repo state, current branch, any prior decisions in this workflow (branch intent for commit messages, base branch for rebasing, etc.). -3. **Invoke git-orchestrate agent** — call it with: - - `operation`: the git operation (e.g., "commit", "create-branch", "rebase") - - `parameters`: user-provided or inferred options - - `context`: decisions and repo state from prior steps in this workflow - - `confirm`: `true` if a destructive op and the user confirmed, otherwise omit -4. **Handle the response** — if orchestrator succeeds, present results in plain language with progress updates and explanations. If it fails, show the error reason and suggest recovery actions. -5. **Clarification prompts** — if the orchestrator needs more information (e.g., "Which branch should this be based on?"), prompt the user conversationally and loop back with the user's input. -6. **Confirmation gates** — before executing any destructive op (force-push, branch deletion, rebase, force-checkout), show what will happen and ask "Proceed?" If the user declines, cancel gracefully. +1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) + and any options the user named. +2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or + touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule + before acting, not after. +3. **Read the repo** — current branch, working-tree state, and which branching model the repo + follows (the orchestrator reads `branching_pattern` from plugin config; infer from branch names + if absent); the last of those decides which tips are worth offering. +4. **Gate destructive operations** — before force-push, branch deletion, rebase, or + force-checkout, show what will happen and ask "Proceed?". Cancel gracefully if the user + declines. Never supply the confirmation on the user's behalf. Some operations are refusals, not + confirmations: never offer "Proceed?" for a force-push of `main` or `master`. +5. **Invoke the `git-orchestrate` agent** with `operation`, `parameters` (user-provided or + inferred), `context` (step 3 plus the session context), and `confirm: true` only for a + destructive op the user approved in step 4. +6. **Clarify when the orchestrator asks for more** — put its question to the user in plain + language ("Which branch should this be based on?") and loop back to step 5 with the answer. +7. **Report the outcome** — on success, the result and what changed, in plain language; on + failure, the error reason and a recovery action. ## Interaction style -- **Conversational**: Use natural language, not technical jargon. "Let me rebase your changes onto main" not "Running git rebase --interactive main". -- **Pedagogical**: Explain what each step does and why. "I'm squashing your last 3 commits into one clean commit" not just "Squashing commits". -- **Guided**: Offer inline help. When users mention ambiguous steps, suggest best practices. Match the tip to the repo's branching model: for Gitflow-style repos, "Tip: Feature branches branch off `develop`, not `main` — `main` only tracks released code." For trunk-based/GitHub Flow repos, "Tip: Short-lived feature branches off `main` keep merges small and reviewable." -- **Transparent**: Show progress. "Creating branch feature/user-auth..." then "✓ Branch created. Ready to commit." Humans benefit from seeing workflow state. -- **Safe**: Always confirm before destructive ops. Never silently rewrite history or force-push without explicit user approval. +The caller is a human, so the interaction is the point. Explain each step and why it happens ("I'm +squashing your last 3 commits into one clean commit" beats "Squashing commits"), show progress as +you go, and prefer natural language to raw command lines. + +Match tips to the repo's branching model rather than offering generic advice: on a Gitflow repo, +feature branches come off `develop` and `main` tracks only released code; on a trunk-based or +GitHub Flow repo, short-lived branches off `main` keep merges small and reviewable. diff --git a/plugins/git/skills/git-workflow/references/README.md b/plugins/git/skills/git-workflow/references/README.md index 37edf15..bcf2db0 100644 --- a/plugins/git/skills/git-workflow/references/README.md +++ b/plugins/git/skills/git-workflow/references/README.md @@ -9,8 +9,11 @@ source_keys: # References -This directory contains provenance metadata and research sources for the `git-workflow` skill. +This directory contains the org git rules and the provenance metadata for the `git-workflow` +skill. ## Files +- `hard-rules.md` — The org's non-negotiable git rules, loaded when a request creates, amends, or + rewrites a commit, pushes, or touches hooks, config, or credentials - `sources.md` — Extracted research sources and their contributing documents diff --git a/plugins/git/skills/git-workflow/references/hard-rules.md b/plugins/git/skills/git-workflow/references/hard-rules.md new file mode 100644 index 0000000..fd2262d --- /dev/null +++ b/plugins/git/skills/git-workflow/references/hard-rules.md @@ -0,0 +1,25 @@ +--- +source_keys: + - org-git-conventions +--- + +# Org git hard rules + +Non-negotiable regardless of what the user asks for. Surface the relevant one proactively rather +than waiting for the user to hit it — a human invoking this skill directly never sees the +orchestrator's copy of these rules, so raise them here. + +- Never skip hooks with `--no-verify` — hooks are the automated QA gate, and bypassing them breaks + the pipeline for everyone downstream. +- Never force-push `main` or `master`. +- Keep commits atomic — each commit should represent one logical, independently reviewable and + reversible change. +- Every commit must leave the repository in a working state (buildable/testable where practical). +- Commit messages explain **why**, not **what** — the diff already documents what changed. +- Never commit secrets, credentials, or environment-specific config. +- Use Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`, etc.). +- Reference related issues, ADRs, or design documents using Git trailers when applicable. + +If a user's request conflicts with one of these (e.g. "force-push main to fix this"), explain the +rule and propose a safe alternative instead of complying. Do not comply and note the rule +afterwards. diff --git a/plugins/git/skills/git-workflow/references/sources.md b/plugins/git/skills/git-workflow/references/sources.md index b5e6c4a..1d8177c 100644 --- a/plugins/git/skills/git-workflow/references/sources.md +++ b/plugins/git/skills/git-workflow/references/sources.md @@ -56,4 +56,5 @@ - **Research doc:** none — org convention, not part of the plugin's research corpus (no `plugins/git/docs/research/` topic file backs this entry) **Contributing files:** -- SKILL.md (Gotchas — Hard rules subsection, rtk git note) +- references/hard-rules.md (whole file — the eight hard rules and the conflict-handling rule) +- SKILL.md (Gotchas — `rtk git` wrapper note) -- 2.43.0 From 261e5b5491e971fe01bf64342482d531ecb7c916 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 19/89] refactor(git-submodules): retrofit to the ADR-0020 context contract Description 480 -> 248 chars, body 1011 -> 347 words. The single submodules.md splits into setup-and-update, urls-and-config, and removal. Restores three regressions the first pass introduced: 'repointing' as the trigger for the URL branch, which had none while the boundary clause steered those queries to git-remotes; clone and absorbgitdirs in the output enum, which dispatch still routed to; and status --cached, the flag that makes the pre-commit pointer gate verifiable. --- .../git/.apm/skills/git-submodules/README.md | 21 +++- .../git/.apm/skills/git-submodules/SKILL.md | 95 +++++++------------ .../git-submodules/references/README.md | 22 ++++- .../git-submodules/references/removal.md | 32 +++++++ .../references/setup-and-update.md | 74 +++++++++++++++ .../git-submodules/references/sources.md | 4 +- .../git-submodules/references/submodules.md | 93 ------------------ .../references/urls-and-config.md | 79 +++++++++++++++ plugins/git/skills/git-submodules/README.md | 21 +++- plugins/git/skills/git-submodules/SKILL.md | 95 +++++++------------ .../git-submodules/references/README.md | 22 ++++- .../git-submodules/references/removal.md | 32 +++++++ .../references/setup-and-update.md | 74 +++++++++++++++ .../git-submodules/references/sources.md | 4 +- .../git-submodules/references/submodules.md | 93 ------------------ .../references/urls-and-config.md | 79 +++++++++++++++ 16 files changed, 512 insertions(+), 328 deletions(-) create mode 100644 plugins/git/.apm/skills/git-submodules/references/removal.md create mode 100644 plugins/git/.apm/skills/git-submodules/references/setup-and-update.md delete mode 100644 plugins/git/.apm/skills/git-submodules/references/submodules.md create mode 100644 plugins/git/.apm/skills/git-submodules/references/urls-and-config.md create mode 100644 plugins/git/skills/git-submodules/references/removal.md create mode 100644 plugins/git/skills/git-submodules/references/setup-and-update.md delete mode 100644 plugins/git/skills/git-submodules/references/submodules.md create mode 100644 plugins/git/skills/git-submodules/references/urls-and-config.md diff --git a/plugins/git/.apm/skills/git-submodules/README.md b/plugins/git/.apm/skills/git-submodules/README.md index 7018f06..06aa066 100644 --- a/plugins/git/.apm/skills/git-submodules/README.md +++ b/plugins/git/.apm/skills/git-submodules/README.md @@ -1,10 +1,17 @@ # git-submodules -Initialize, clone, update, and manage git submodules for multi-repository projects. +Add, initialize, update, pin, inspect, and remove git submodules in multi-repository projects. ## What it does -This skill handles submodule operations within the git workflow suite. It initializes submodules, clones repositories with nested submodule dependencies, updates submodule pinning, and manages version control across multi-repo projects. The skill provides clean workflows for projects with complex dependency structures and returns structured results suitable for agent composition. +This skill handles submodule operations within the git workflow suite: cloning a superproject with +its nested repositories, adding a dependency as a submodule, initializing and updating with +pinning or branch tracking, parallel and recursive traversal, rebinding URLs and tracked branches, +and the full removal sequence including the `.git/modules/` cleanup git leaves behind. It returns +structured results suitable for agent composition. + +It sits alongside the other git skills rather than duplicating them: `git-worktrees` covers +multiple checkouts of a single repository, and `git-remotes` covers the superproject's own remotes. ## Usage @@ -12,13 +19,17 @@ This skill handles submodule operations within the git workflow suite. It initia /git-submodules ``` -Describe your submodule task: initialize, clone, update, or manage versions. The skill will handle the operation and return structured results (operation, status, per-submodule details, conflicts, and a recovery `next_step` when applicable) suitable for agent composition. +Describe the submodule task. The skill applies the shared working rules, dispatches to the +reference for that task, and returns structured results (operation, status, per-submodule details, +conflicts, and a recovery `next_step` when applicable). ## Files | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table | | `references/README.md` | Describes contents of references/ | -| `references/submodules.md` | Deep-dive reference: full flag tables, workflow patterns, safe-removal sequence, `absorbgitdirs`, `foreach` variables | +| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, or initializing, updating, or re-pinning one — includes the full `add` and `update` flag tables and the pinning workflows | +| `references/urls-and-config.md` | Loaded when changing where a submodule points or how it is configured — `.gitmodules` vs `.git/config` anatomy, both key tables, `sync`/`set-url`/`set-branch`, local mirror overrides, relative URLs, the custom-`update` security gate, and `absorbgitdirs` | +| `references/removal.md` | Loaded when removing or deinitializing a submodule — why `deinit` is not removal, and the four-step removal sequence | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/.apm/skills/git-submodules/SKILL.md b/plugins/git/.apm/skills/git-submodules/SKILL.md index 147aee3..1fc6d8d 100644 --- a/plugins/git/.apm/skills/git-submodules/SKILL.md +++ b/plugins/git/.apm/skills/git-submodules/SKILL.md @@ -2,7 +2,10 @@ name: git-submodules description: > - Use when managing Git submodules: add dependencies as submodules, initialize and update nested repositories, sync URLs, inspect status (including detached HEAD and divergence), and safely remove submodules. Handles multi-repo projects with pinning, parallel operations, and recursive traversal. Use for both initial setup and ongoing maintenance workflows, even if the user doesn't explicitly say "submodule". Do not use for general git operations outside of submodule management. + Use when managing Git submodules — adding, updating, pinning, inspecting, + repointing, or removing a nested repository inside a superproject. + Not multiple checkouts of one repo -> `git-worktrees`. + Not the superproject's own remotes -> `git-remotes`. metadata: category: git @@ -10,82 +13,50 @@ metadata: - git-scm-submodule-docs --- -## Concept - -A submodule is a full Git repository embedded as a subdirectory inside a parent repository (the superproject). The superproject doesn't store the submodule's files — it stores a pointer to a specific commit SHA in the submodule's own history, and the two repos keep fully independent commit histories. - -Two files govern a submodule, and they serve different audiences: - -- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's name, path, and canonical URL. -- **`.git/config`** — local only, populated by `git submodule init`. This is where local URL overrides live (e.g. a private mirror) — they never propagate to other clones. - -The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject, linked to the submodule's working tree via a `.git` pointer file. After `git submodule update`, the working tree normally ends up in **detached HEAD state** — see Gotchas. - ## Gotchas -- **Detached HEAD by default.** `git submodule update` checks out a specific commit, not a branch. Work on a branch first, then update the pointer in the superproject. Commits made in detached state are invisible until pinned. -- **Two pushes required, in order.** Always commit and push the submodule first, then update and push the superproject's pointer. The superproject only stores a commit SHA — if that SHA isn't reachable on the submodule's remote yet, `git submodule update` fails for anyone who pulls the superproject before the submodule push lands. -- **`--recursive` is not default.** Most commands operate one level deep. Pass `--recursive` explicitly for nested submodules. -- **`.git/modules/` persists after `git rm`.** Manual cleanup is needed: `rm -rf .git/modules/<name>/`. -- **Detached HEAD detection.** Status prefix `+` means the checked-out commit differs from the superproject's recorded commit — normal after `update --remote`, but should be re-pinned before committing. -- **Relative URLs resolve against the remote, not the filesystem.** A `../foo.git` entry in `.gitmodules` is relative to the superproject's default remote URL. -- **Custom `update` commands are security-gated.** A `.gitmodules` entry of `update = !some-command` is never copied to `.git/config` by `git submodule init` — this stops a clone from silently executing arbitrary code. +- **`update` leaves the submodule in detached HEAD.** Branch inside the submodule before editing, or the work is unreachable once the pointer moves. +- **Push the submodule before the superproject.** The superproject stores only a SHA, and one missing from the submodule's remote breaks every collaborator's `update`. +- **`--recursive` is never the default.** Subcommands stop one level deep, so nested submodules go stale silently. +- **`git rm` leaves `.git/modules/<name>/` behind.** Nothing cleans it up, and it blocks re-adding a submodule there. -## Conventions +## Working rules -- **Use `rtk git` for parent-repo operations.** Drop into the submodule directory only for submodule-specific git commands (committing/pushing inside the submodule itself) — mixing the two from the wrong working directory targets the wrong repo's history. -- **Check for a dirty submodule before committing the parent pointer.** After adding or updating a submodule, run `git status` in both the parent and the submodule. A `-dirty` suffix means the submodule has uncommitted local changes; committing the parent pointer now would pin a state no one else can reproduce, since those changes exist only in the local working tree. +Run `rtk git` from the superproject root. Enter the submodule directory only for commits and pushes +that belong to the submodule's own history — the two repositories have independent histories, and +the same command from the wrong directory writes to the wrong one. -## Operations +Before committing a superproject pointer, run `rtk git submodule status --recursive`. Prefixes: `-` +not initialized, `+` working tree differs from the recorded commit, `U` merge conflict. Add +`--cached` to read the SHAs the superproject index will record rather than the working-tree state. +A `-dirty` suffix means uncommitted changes inside the submodule, and committing the pointer over +them pins a state nobody else can reproduce. -- **Clone a repo that has submodules**: `rtk git clone --recurse-submodules <url>` (one step, Git 2.13+) or `rtk git clone <url>` followed by `rtk git submodule update --init --recursive`. -- **Add a submodule**: `rtk git submodule add <url> <path>` (`-b <branch>` to track a branch instead of a pinned commit, `--depth 1` for a shallow clone, `-f` to force past a gitignored path or name conflict, `--name <name>` when the logical name should differ from the path). Stages a `.gitmodules` entry and a gitlink — a commit is still required. -- **Initialize**: `rtk git submodule init [<path>...]` copies submodule URLs from `.gitmodules` to `.git/config`. This is the point at which local URL overrides can be edited before fetching. Does not clone — use `update` (or `update --init` to run both in one step). -- **Update (clone + checkout)**: `rtk git submodule update --init --recursive` is the common case — checks out the recorded commit in detached HEAD. Add `--remote --merge` (or `--remote --rebase`) to track the branch tip instead, `--jobs <n>` for parallel clones, `-f` to discard local changes. Full flag table: `references/submodules.md`. -- **Inspect status**: `rtk git submodule status --recursive` (add `--cached` to show SHAs in the superproject index instead of the working tree). Status prefixes: `-` not initialized, `+` diverged from the superproject's recorded commit, `U` merge conflict. -- **Sync and rebind URLs**: `rtk git submodule sync --recursive` after an upstream URL rename propagates `.gitmodules` changes into `.git/config`. `rtk git submodule set-url <path> <url>` changes a URL directly; `rtk git submodule set-branch -b <branch> <path>` sets the tracking branch used by `update --remote`. -- **Override a submodule URL locally (private mirror)**: local-only, doesn't propagate to collaborators, and gets overwritten by the next `sync`. Full steps: `references/submodules.md`. -- **Run a command across all submodules**: `rtk git submodule foreach --recursive '<command>'`. Shell variables available inside `<command>` (`$name`, `$sm_path`, `$displaypath`, `$sha1`, `$toplevel`): `references/submodules.md`. -- **Deinit (unregister without removing)**: `rtk git submodule deinit <path>` (`--all` for every submodule, `-f` if local modifications are present) clears the `.git/config` section and empties the working tree. **`deinit` is not removal** — the `.gitmodules` entry and the gitlink in the superproject's index are untouched. -- **Safe removal** (destructive; confirm before executing) — full three-step sequence including the manual `.git/modules/` cleanup: `references/submodules.md`. -- **Move an embedded `.git` into `.git/modules/`**: `rtk git submodule absorbgitdirs [<path>...]` — needed when a submodule was created or copied without going through `git submodule add`. Details: `references/submodules.md`. +To run one command across every submodule: `rtk git submodule foreach --recursive '<cmd>'`. Inside +`<cmd>`, Git sets `$name`, `$sm_path`, `$displaypath`, `$sha1` and `$toplevel`; append `|| :` to +continue past a failure instead of aborting the traversal. -## Configuration +## Dispatch -`.gitmodules` (version-controlled, shared with collaborators): +Read only the row that matches the request. -| Key | Purpose | +| Task | Reference | |---|---| -| `submodule.<name>.path` | Working tree path | -| `submodule.<name>.url` | Remote URL | -| `submodule.<name>.branch` | Branch used by `update --remote` | -| `submodule.<name>.update` | Default update procedure | -| `submodule.<name>.shallow` | Recommend shallow clone | +| Clone a superproject with submodules, or add, initialize, update or re-pin one | `references/setup-and-update.md` | +| Change where a submodule points — `sync`, `set-url`, `set-branch`, a local mirror override, `absorbgitdirs`, or any `.gitmodules` / `.git/config` key | `references/urls-and-config.md` | +| Remove a submodule, or `deinit` one without removing it | `references/removal.md` | -`.git/config` (local only, populated by `init`): +Removal and `deinit` are destructive: state what will be deleted and get confirmation before +executing. -| Key | Purpose | -|---|---| -| `submodule.<name>.url` | Local URL override | -| `submodule.<name>.update` | Local procedure override | -| `submodule.fetchJobs` | Default parallelism for `update --jobs` | -| `submodule.recurse` | Auto-recurse submodule updates on `pull`/`push`/etc. | +## Output format -```bash -rtk git config submodule.recurse true # keep submodules pinned automatically after every pull ``` - -## Agent output format - -Return results as structured data: -``` -operation: <clone|add|init|update|sync|set-url|set-branch|status|summary|absorbgitdirs|remove> +operation: <clone|add|init|update|status|sync|set-url|set-branch|absorbgitdirs|deinit|remove> status: <success|error|partial> -message: <human-readable summary> +message: <one line; include git's own output on error> details: - <submodule-path>: <state> -conflicts: [<submodule-path>, ...] # if any -next_step: <recovery action if applicable> +conflicts: [<submodule-path>, ...] +next_step: <recovery action, when status is error or partial> ``` - -For errors, include the git command output and recommend recovery (e.g., `git submodule deinit`, force-update, or URL override). diff --git a/plugins/git/.apm/skills/git-submodules/references/README.md b/plugins/git/.apm/skills/git-submodules/references/README.md index fd8bfc5..244de95 100644 --- a/plugins/git/.apm/skills/git-submodules/references/README.md +++ b/plugins/git/.apm/skills/git-submodules/references/README.md @@ -6,10 +6,26 @@ metadata: # References -## submodules.md +One file per task branch in SKILL.md's dispatch table. Load only the one that matches the request. -Deep-dive reference: full `update` flag table, workflow patterns (clone, add, keep-pinned, update-to-latest, override URL), the complete safe-removal sequence, `absorbgitdirs`, and `foreach` shell variables. Load when SKILL.md's condensed Operations list isn't enough detail. +## setup-and-update.md + +Cloning a superproject that has submodules, adding a dependency as a submodule, initializing +without cloning, and updating or re-pinning. Carries the `add` and `update` flag tables and the +keep-pinned and move-the-pin-forward workflows. + +## urls-and-config.md + +Where a submodule points and how it is configured: the `.gitmodules` vs `.git/config` split, both +key tables, `sync` / `set-url` / `set-branch`, local mirror overrides, relative URL resolution, the +security gate on custom `update` commands, and `absorbgitdirs`. + +## removal.md + +Removing a submodule, and why `deinit` alone does not remove one. Carries the full four-step +removal sequence including the manual `.git/modules/<name>/` cleanup. ## sources.md -Research sources that informed this skill — provenance chain for git-scm-submodule-docs reference material. +Research sources that informed this skill — provenance chain for git-scm-submodule-docs reference +material. diff --git a/plugins/git/.apm/skills/git-submodules/references/removal.md b/plugins/git/.apm/skills/git-submodules/references/removal.md new file mode 100644 index 0000000..4bee62e --- /dev/null +++ b/plugins/git/.apm/skills/git-submodules/references/removal.md @@ -0,0 +1,32 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Removing and deinitializing a submodule + +Both operations are destructive. Confirm with the user before executing either. + +## `deinit` is not removal + +```bash +git submodule deinit <path> # --all for every submodule, -f if locally modified +``` + +`deinit` clears the submodule's section from `.git/config` and empties its working tree. The +`.gitmodules` entry and the gitlink in the superproject's index are untouched, so the submodule is +still registered and a later `update --init` brings it straight back. Use it to reclaim disk space +or to reset a broken checkout, not to delete a dependency. + +## Full removal, in order + +```bash +git submodule deinit -f <path> # unregister from .git/config +git rm <path> # drop the .gitmodules entry and the gitlink from the index +rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git +git commit -m "chore: remove <name> submodule" +``` + +The third step is the one that gets skipped. `.git/modules/<name>/` survives `git rm`, and while it +is present Git refuses to add a submodule at the same path again. diff --git a/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md new file mode 100644 index 0000000..e7bae51 --- /dev/null +++ b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md @@ -0,0 +1,74 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Adding, initializing, updating and pinning submodules + +## Clone a superproject that already has submodules + +```bash +git clone --recurse-submodules <url> # Git 2.13+, one step +# or, against an existing clone +git submodule update --init --recursive +``` + +## Add a dependency as a submodule + +```bash +git submodule add <url> <path> +git commit -m "chore: add <name> as submodule" +``` + +`add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: + +| Flag | Meaning | +|---|---| +| `-b <branch>` | Track a branch (`submodule.<name>.branch`) instead of only a pinned commit | +| `--depth <n>` | Shallow clone | +| `-f` | Force past a gitignored path or a name conflict | +| `--name <name>` | Logical name differing from the path | + +## Initialize without cloning + +`git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and +does nothing else. This is the point at which a local URL override can be edited before any fetch +happens. If a local mirror override is wanted, read `references/urls-and-config.md` before running +`update`. Use `update --init` to run both steps at once. + +## Update + +`git submodule update --init --recursive` is the common case: it clones what is missing and checks +out the commit the superproject recorded, in detached HEAD. + +| Flag | Meaning | +|---|---| +| `--init` | Run `init` first, avoiding a separate step | +| `--remote` | Use the submodule's remote branch tip instead of the superproject's recorded commit | +| `--checkout` | Detached HEAD at the recorded commit (default) | +| `--rebase` | Rebase the current branch onto the recorded commit | +| `--merge` | Merge the recorded commit into the current branch | +| `--recursive` | Operate on nested submodules | +| `--jobs <n>` | Parallel clone (defaults to `submodule.fetchJobs`) | +| `-N` / `--no-fetch` | Skip the remote fetch | +| `-f` | Discard local changes in the submodule working tree | +| `--depth <n>` | Shallow clone | +| `--filter <spec>` | Partial clone filter | + +## Keep submodules pinned to the recorded commit + +```bash +git submodule update --recursive # after every git pull +git config submodule.recurse true # or do it automatically on pull/push/checkout +``` + +## Move the pin forward to the tracked branch tip + +```bash +git submodule update --remote --merge --recursive +git commit -am "chore: update submodules to latest" +``` + +`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default +branch. Commit the superproject afterwards or the new pin is lost on the next `update`. diff --git a/plugins/git/.apm/skills/git-submodules/references/sources.md b/plugins/git/.apm/skills/git-submodules/references/sources.md index 15658aa..41c8660 100644 --- a/plugins/git/.apm/skills/git-submodules/references/sources.md +++ b/plugins/git/.apm/skills/git-submodules/references/sources.md @@ -14,7 +14,9 @@ source_keys: **Contributing files:** - SKILL.md (all sections) -- references/submodules.md (all sections) +- references/setup-and-update.md (all sections) +- references/urls-and-config.md (all sections) +- references/removal.md (all sections) --- diff --git a/plugins/git/.apm/skills/git-submodules/references/submodules.md b/plugins/git/.apm/skills/git-submodules/references/submodules.md deleted file mode 100644 index ce056a7..0000000 --- a/plugins/git/.apm/skills/git-submodules/references/submodules.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -topic: submodules -source_keys: - - git-scm-submodule-docs ---- - -# Submodules — Deep Reference - -## Update flag reference - -| Flag | Meaning | -|---|---| -| `--init` | Run init first (avoids a separate step) | -| `--remote` | Use the submodule's remote branch tip instead of the superproject's recorded commit | -| `--checkout` | Detached HEAD at recorded commit (default) | -| `--rebase` | Rebase current branch onto recorded commit | -| `--merge` | Merge recorded commit into current branch | -| `--recursive` | Operate on nested submodules | -| `--jobs <n>` | Parallel clone (defaults to `submodule.fetchJobs`) | -| `-N` / `--no-fetch` | Skip remote fetch | -| `--depth <n>` | Shallow clone | -| `--filter <spec>` | Partial clone filter | - -## Workflow patterns - -### Clone a repo with submodules -```bash -git clone --recurse-submodules <url> # Git 2.13+, one step -# or -git clone <url> -git submodule update --init --recursive -``` - -### Add a dependency as a submodule -```bash -git submodule add https://github.com/org/lib.git libs/lib -git commit -m "chore: add lib as submodule" -``` - -### Keep submodules pinned to the superproject's recorded commit -```bash -git submodule update --recursive # after every git pull -git config submodule.recurse true # do this automatically on pull -``` - -### Update submodules to the latest commit on their tracked branch -```bash -git submodule update --remote --merge --recursive -git commit -am "chore: update submodules to latest" -``` - -### Override a submodule URL locally (private mirror) -```bash -git submodule init -# edit .git/config: submodule.<name>.url = <mirror-url> -git submodule update -``` -Local-only override (`.git/config`, not `.gitmodules`) — doesn't propagate to collaborators. Re-running `sync` overwrites it with the `.gitmodules` URL. - -## Removal, in full - -`deinit` alone does not remove a submodule — it only clears `.git/config` and empties the working tree. To fully remove: -```bash -git submodule deinit -f <path> # unregister from .git/config -git rm <path> # remove .gitmodules entry + gitlink from index -rm -rf .git/modules/<name>/ # stale git dir; not tracked by git, not auto-cleaned -git commit -m "chore: remove <name> submodule" -``` -`.git/modules/<name>/` persisting after `git rm` will block re-adding the same path until manually deleted. - -## Relocate an embedded `.git` directory - -```bash -git submodule absorbgitdirs [<path>...] -``` -Moves a submodule's own `.git` directory into the superproject's `.git/modules/<name>/`, linking it back with a `.git` pointer file. Needed when a submodule was created or copied without going through `git submodule add` (e.g. converting a plain nested repo into a proper submodule). - -## `foreach` shell variables - -Available inside the `<command>` argument to `git submodule foreach`: - -| Variable | Meaning | -|---|---| -| `$name` | Logical submodule name | -| `$sm_path` | Path relative to superproject root | -| `$displaypath` | Path relative to current working directory | -| `$sha1` | Recorded commit SHA | -| `$toplevel` | Superproject's root path | - -```bash -git submodule foreach --recursive '<command>' -git submodule foreach 'git pull origin main || :' # || : continues past failures -``` diff --git a/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md b/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md new file mode 100644 index 0000000..5d59691 --- /dev/null +++ b/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md @@ -0,0 +1,79 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Where a submodule points, and how it is configured + +## Two files, two audiences + +- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's + logical name, path, and canonical URL. +- **`.git/config`** — local only, populated by `git submodule init`. Local URL overrides live here + and never propagate to another clone. + +The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is +linked to the submodule's working tree by a `.git` pointer file. + +## `.gitmodules` keys + +| Key | Purpose | +|---|---| +| `submodule.<name>.path` | Working tree path | +| `submodule.<name>.url` | Remote URL | +| `submodule.<name>.branch` | Branch used by `update --remote` | +| `submodule.<name>.update` | Default update procedure | +| `submodule.<name>.shallow` | Recommend a shallow clone | + +## `.git/config` keys + +| Key | Purpose | +|---|---| +| `submodule.<name>.url` | Local URL override | +| `submodule.<name>.update` | Local procedure override | +| `submodule.fetchJobs` | Default parallelism for `update --jobs` | +| `submodule.recurse` | Auto-recurse submodule updates on `pull`/`push`/etc. | + +## Rebind a URL or branch + +```bash +git submodule sync --recursive # push .gitmodules URLs into .git/config +git submodule set-url <path> <url> # change the canonical URL +git submodule set-branch -b <branch> <path> # set the branch used by update --remote +``` + +Run `sync` after an upstream rename: existing clones keep the stale URL in `.git/config` until +they do. + +## Override a URL locally (private mirror) + +```bash +git submodule init +# edit .git/config: submodule.<name>.url = <mirror-url> +git submodule update +``` + +Local-only, invisible to collaborators, and overwritten by the next `sync`. + +## Relative URLs + +A `../foo.git` entry in `.gitmodules` resolves against the superproject's default remote URL, not +against the filesystem. It is portable across hosts that mirror the same layout and broken +everywhere else. + +## Custom `update` commands are security-gated + +A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by +`git submodule init`. That is deliberate: it stops a hostile clone from silently executing +arbitrary code. Setting it locally in `.git/config` is the only way to enable it. + +## Relocate an embedded `.git` directory + +```bash +git submodule absorbgitdirs [<path>...] +``` + +Moves a submodule's own `.git` directory into `.git/modules/<name>/` and leaves a `.git` pointer +file behind. Needed when a nested repository was created or copied in without going through +`git submodule add`. diff --git a/plugins/git/skills/git-submodules/README.md b/plugins/git/skills/git-submodules/README.md index 7018f06..06aa066 100644 --- a/plugins/git/skills/git-submodules/README.md +++ b/plugins/git/skills/git-submodules/README.md @@ -1,10 +1,17 @@ # git-submodules -Initialize, clone, update, and manage git submodules for multi-repository projects. +Add, initialize, update, pin, inspect, and remove git submodules in multi-repository projects. ## What it does -This skill handles submodule operations within the git workflow suite. It initializes submodules, clones repositories with nested submodule dependencies, updates submodule pinning, and manages version control across multi-repo projects. The skill provides clean workflows for projects with complex dependency structures and returns structured results suitable for agent composition. +This skill handles submodule operations within the git workflow suite: cloning a superproject with +its nested repositories, adding a dependency as a submodule, initializing and updating with +pinning or branch tracking, parallel and recursive traversal, rebinding URLs and tracked branches, +and the full removal sequence including the `.git/modules/` cleanup git leaves behind. It returns +structured results suitable for agent composition. + +It sits alongside the other git skills rather than duplicating them: `git-worktrees` covers +multiple checkouts of a single repository, and `git-remotes` covers the superproject's own remotes. ## Usage @@ -12,13 +19,17 @@ This skill handles submodule operations within the git workflow suite. It initia /git-submodules ``` -Describe your submodule task: initialize, clone, update, or manage versions. The skill will handle the operation and return structured results (operation, status, per-submodule details, conflicts, and a recovery `next_step` when applicable) suitable for agent composition. +Describe the submodule task. The skill applies the shared working rules, dispatches to the +reference for that task, and returns structured results (operation, status, per-submodule details, +conflicts, and a recovery `next_step` when applicable). ## Files | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table | | `references/README.md` | Describes contents of references/ | -| `references/submodules.md` | Deep-dive reference: full flag tables, workflow patterns, safe-removal sequence, `absorbgitdirs`, `foreach` variables | +| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, or initializing, updating, or re-pinning one — includes the full `add` and `update` flag tables and the pinning workflows | +| `references/urls-and-config.md` | Loaded when changing where a submodule points or how it is configured — `.gitmodules` vs `.git/config` anatomy, both key tables, `sync`/`set-url`/`set-branch`, local mirror overrides, relative URLs, the custom-`update` security gate, and `absorbgitdirs` | +| `references/removal.md` | Loaded when removing or deinitializing a submodule — why `deinit` is not removal, and the four-step removal sequence | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/skills/git-submodules/SKILL.md b/plugins/git/skills/git-submodules/SKILL.md index 147aee3..1fc6d8d 100644 --- a/plugins/git/skills/git-submodules/SKILL.md +++ b/plugins/git/skills/git-submodules/SKILL.md @@ -2,7 +2,10 @@ name: git-submodules description: > - Use when managing Git submodules: add dependencies as submodules, initialize and update nested repositories, sync URLs, inspect status (including detached HEAD and divergence), and safely remove submodules. Handles multi-repo projects with pinning, parallel operations, and recursive traversal. Use for both initial setup and ongoing maintenance workflows, even if the user doesn't explicitly say "submodule". Do not use for general git operations outside of submodule management. + Use when managing Git submodules — adding, updating, pinning, inspecting, + repointing, or removing a nested repository inside a superproject. + Not multiple checkouts of one repo -> `git-worktrees`. + Not the superproject's own remotes -> `git-remotes`. metadata: category: git @@ -10,82 +13,50 @@ metadata: - git-scm-submodule-docs --- -## Concept - -A submodule is a full Git repository embedded as a subdirectory inside a parent repository (the superproject). The superproject doesn't store the submodule's files — it stores a pointer to a specific commit SHA in the submodule's own history, and the two repos keep fully independent commit histories. - -Two files govern a submodule, and they serve different audiences: - -- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's name, path, and canonical URL. -- **`.git/config`** — local only, populated by `git submodule init`. This is where local URL overrides live (e.g. a private mirror) — they never propagate to other clones. - -The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject, linked to the submodule's working tree via a `.git` pointer file. After `git submodule update`, the working tree normally ends up in **detached HEAD state** — see Gotchas. - ## Gotchas -- **Detached HEAD by default.** `git submodule update` checks out a specific commit, not a branch. Work on a branch first, then update the pointer in the superproject. Commits made in detached state are invisible until pinned. -- **Two pushes required, in order.** Always commit and push the submodule first, then update and push the superproject's pointer. The superproject only stores a commit SHA — if that SHA isn't reachable on the submodule's remote yet, `git submodule update` fails for anyone who pulls the superproject before the submodule push lands. -- **`--recursive` is not default.** Most commands operate one level deep. Pass `--recursive` explicitly for nested submodules. -- **`.git/modules/` persists after `git rm`.** Manual cleanup is needed: `rm -rf .git/modules/<name>/`. -- **Detached HEAD detection.** Status prefix `+` means the checked-out commit differs from the superproject's recorded commit — normal after `update --remote`, but should be re-pinned before committing. -- **Relative URLs resolve against the remote, not the filesystem.** A `../foo.git` entry in `.gitmodules` is relative to the superproject's default remote URL. -- **Custom `update` commands are security-gated.** A `.gitmodules` entry of `update = !some-command` is never copied to `.git/config` by `git submodule init` — this stops a clone from silently executing arbitrary code. +- **`update` leaves the submodule in detached HEAD.** Branch inside the submodule before editing, or the work is unreachable once the pointer moves. +- **Push the submodule before the superproject.** The superproject stores only a SHA, and one missing from the submodule's remote breaks every collaborator's `update`. +- **`--recursive` is never the default.** Subcommands stop one level deep, so nested submodules go stale silently. +- **`git rm` leaves `.git/modules/<name>/` behind.** Nothing cleans it up, and it blocks re-adding a submodule there. -## Conventions +## Working rules -- **Use `rtk git` for parent-repo operations.** Drop into the submodule directory only for submodule-specific git commands (committing/pushing inside the submodule itself) — mixing the two from the wrong working directory targets the wrong repo's history. -- **Check for a dirty submodule before committing the parent pointer.** After adding or updating a submodule, run `git status` in both the parent and the submodule. A `-dirty` suffix means the submodule has uncommitted local changes; committing the parent pointer now would pin a state no one else can reproduce, since those changes exist only in the local working tree. +Run `rtk git` from the superproject root. Enter the submodule directory only for commits and pushes +that belong to the submodule's own history — the two repositories have independent histories, and +the same command from the wrong directory writes to the wrong one. -## Operations +Before committing a superproject pointer, run `rtk git submodule status --recursive`. Prefixes: `-` +not initialized, `+` working tree differs from the recorded commit, `U` merge conflict. Add +`--cached` to read the SHAs the superproject index will record rather than the working-tree state. +A `-dirty` suffix means uncommitted changes inside the submodule, and committing the pointer over +them pins a state nobody else can reproduce. -- **Clone a repo that has submodules**: `rtk git clone --recurse-submodules <url>` (one step, Git 2.13+) or `rtk git clone <url>` followed by `rtk git submodule update --init --recursive`. -- **Add a submodule**: `rtk git submodule add <url> <path>` (`-b <branch>` to track a branch instead of a pinned commit, `--depth 1` for a shallow clone, `-f` to force past a gitignored path or name conflict, `--name <name>` when the logical name should differ from the path). Stages a `.gitmodules` entry and a gitlink — a commit is still required. -- **Initialize**: `rtk git submodule init [<path>...]` copies submodule URLs from `.gitmodules` to `.git/config`. This is the point at which local URL overrides can be edited before fetching. Does not clone — use `update` (or `update --init` to run both in one step). -- **Update (clone + checkout)**: `rtk git submodule update --init --recursive` is the common case — checks out the recorded commit in detached HEAD. Add `--remote --merge` (or `--remote --rebase`) to track the branch tip instead, `--jobs <n>` for parallel clones, `-f` to discard local changes. Full flag table: `references/submodules.md`. -- **Inspect status**: `rtk git submodule status --recursive` (add `--cached` to show SHAs in the superproject index instead of the working tree). Status prefixes: `-` not initialized, `+` diverged from the superproject's recorded commit, `U` merge conflict. -- **Sync and rebind URLs**: `rtk git submodule sync --recursive` after an upstream URL rename propagates `.gitmodules` changes into `.git/config`. `rtk git submodule set-url <path> <url>` changes a URL directly; `rtk git submodule set-branch -b <branch> <path>` sets the tracking branch used by `update --remote`. -- **Override a submodule URL locally (private mirror)**: local-only, doesn't propagate to collaborators, and gets overwritten by the next `sync`. Full steps: `references/submodules.md`. -- **Run a command across all submodules**: `rtk git submodule foreach --recursive '<command>'`. Shell variables available inside `<command>` (`$name`, `$sm_path`, `$displaypath`, `$sha1`, `$toplevel`): `references/submodules.md`. -- **Deinit (unregister without removing)**: `rtk git submodule deinit <path>` (`--all` for every submodule, `-f` if local modifications are present) clears the `.git/config` section and empties the working tree. **`deinit` is not removal** — the `.gitmodules` entry and the gitlink in the superproject's index are untouched. -- **Safe removal** (destructive; confirm before executing) — full three-step sequence including the manual `.git/modules/` cleanup: `references/submodules.md`. -- **Move an embedded `.git` into `.git/modules/`**: `rtk git submodule absorbgitdirs [<path>...]` — needed when a submodule was created or copied without going through `git submodule add`. Details: `references/submodules.md`. +To run one command across every submodule: `rtk git submodule foreach --recursive '<cmd>'`. Inside +`<cmd>`, Git sets `$name`, `$sm_path`, `$displaypath`, `$sha1` and `$toplevel`; append `|| :` to +continue past a failure instead of aborting the traversal. -## Configuration +## Dispatch -`.gitmodules` (version-controlled, shared with collaborators): +Read only the row that matches the request. -| Key | Purpose | +| Task | Reference | |---|---| -| `submodule.<name>.path` | Working tree path | -| `submodule.<name>.url` | Remote URL | -| `submodule.<name>.branch` | Branch used by `update --remote` | -| `submodule.<name>.update` | Default update procedure | -| `submodule.<name>.shallow` | Recommend shallow clone | +| Clone a superproject with submodules, or add, initialize, update or re-pin one | `references/setup-and-update.md` | +| Change where a submodule points — `sync`, `set-url`, `set-branch`, a local mirror override, `absorbgitdirs`, or any `.gitmodules` / `.git/config` key | `references/urls-and-config.md` | +| Remove a submodule, or `deinit` one without removing it | `references/removal.md` | -`.git/config` (local only, populated by `init`): +Removal and `deinit` are destructive: state what will be deleted and get confirmation before +executing. -| Key | Purpose | -|---|---| -| `submodule.<name>.url` | Local URL override | -| `submodule.<name>.update` | Local procedure override | -| `submodule.fetchJobs` | Default parallelism for `update --jobs` | -| `submodule.recurse` | Auto-recurse submodule updates on `pull`/`push`/etc. | +## Output format -```bash -rtk git config submodule.recurse true # keep submodules pinned automatically after every pull ``` - -## Agent output format - -Return results as structured data: -``` -operation: <clone|add|init|update|sync|set-url|set-branch|status|summary|absorbgitdirs|remove> +operation: <clone|add|init|update|status|sync|set-url|set-branch|absorbgitdirs|deinit|remove> status: <success|error|partial> -message: <human-readable summary> +message: <one line; include git's own output on error> details: - <submodule-path>: <state> -conflicts: [<submodule-path>, ...] # if any -next_step: <recovery action if applicable> +conflicts: [<submodule-path>, ...] +next_step: <recovery action, when status is error or partial> ``` - -For errors, include the git command output and recommend recovery (e.g., `git submodule deinit`, force-update, or URL override). diff --git a/plugins/git/skills/git-submodules/references/README.md b/plugins/git/skills/git-submodules/references/README.md index fd8bfc5..244de95 100644 --- a/plugins/git/skills/git-submodules/references/README.md +++ b/plugins/git/skills/git-submodules/references/README.md @@ -6,10 +6,26 @@ metadata: # References -## submodules.md +One file per task branch in SKILL.md's dispatch table. Load only the one that matches the request. -Deep-dive reference: full `update` flag table, workflow patterns (clone, add, keep-pinned, update-to-latest, override URL), the complete safe-removal sequence, `absorbgitdirs`, and `foreach` shell variables. Load when SKILL.md's condensed Operations list isn't enough detail. +## setup-and-update.md + +Cloning a superproject that has submodules, adding a dependency as a submodule, initializing +without cloning, and updating or re-pinning. Carries the `add` and `update` flag tables and the +keep-pinned and move-the-pin-forward workflows. + +## urls-and-config.md + +Where a submodule points and how it is configured: the `.gitmodules` vs `.git/config` split, both +key tables, `sync` / `set-url` / `set-branch`, local mirror overrides, relative URL resolution, the +security gate on custom `update` commands, and `absorbgitdirs`. + +## removal.md + +Removing a submodule, and why `deinit` alone does not remove one. Carries the full four-step +removal sequence including the manual `.git/modules/<name>/` cleanup. ## sources.md -Research sources that informed this skill — provenance chain for git-scm-submodule-docs reference material. +Research sources that informed this skill — provenance chain for git-scm-submodule-docs reference +material. diff --git a/plugins/git/skills/git-submodules/references/removal.md b/plugins/git/skills/git-submodules/references/removal.md new file mode 100644 index 0000000..4bee62e --- /dev/null +++ b/plugins/git/skills/git-submodules/references/removal.md @@ -0,0 +1,32 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Removing and deinitializing a submodule + +Both operations are destructive. Confirm with the user before executing either. + +## `deinit` is not removal + +```bash +git submodule deinit <path> # --all for every submodule, -f if locally modified +``` + +`deinit` clears the submodule's section from `.git/config` and empties its working tree. The +`.gitmodules` entry and the gitlink in the superproject's index are untouched, so the submodule is +still registered and a later `update --init` brings it straight back. Use it to reclaim disk space +or to reset a broken checkout, not to delete a dependency. + +## Full removal, in order + +```bash +git submodule deinit -f <path> # unregister from .git/config +git rm <path> # drop the .gitmodules entry and the gitlink from the index +rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git +git commit -m "chore: remove <name> submodule" +``` + +The third step is the one that gets skipped. `.git/modules/<name>/` survives `git rm`, and while it +is present Git refuses to add a submodule at the same path again. diff --git a/plugins/git/skills/git-submodules/references/setup-and-update.md b/plugins/git/skills/git-submodules/references/setup-and-update.md new file mode 100644 index 0000000..e7bae51 --- /dev/null +++ b/plugins/git/skills/git-submodules/references/setup-and-update.md @@ -0,0 +1,74 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Adding, initializing, updating and pinning submodules + +## Clone a superproject that already has submodules + +```bash +git clone --recurse-submodules <url> # Git 2.13+, one step +# or, against an existing clone +git submodule update --init --recursive +``` + +## Add a dependency as a submodule + +```bash +git submodule add <url> <path> +git commit -m "chore: add <name> as submodule" +``` + +`add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: + +| Flag | Meaning | +|---|---| +| `-b <branch>` | Track a branch (`submodule.<name>.branch`) instead of only a pinned commit | +| `--depth <n>` | Shallow clone | +| `-f` | Force past a gitignored path or a name conflict | +| `--name <name>` | Logical name differing from the path | + +## Initialize without cloning + +`git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and +does nothing else. This is the point at which a local URL override can be edited before any fetch +happens. If a local mirror override is wanted, read `references/urls-and-config.md` before running +`update`. Use `update --init` to run both steps at once. + +## Update + +`git submodule update --init --recursive` is the common case: it clones what is missing and checks +out the commit the superproject recorded, in detached HEAD. + +| Flag | Meaning | +|---|---| +| `--init` | Run `init` first, avoiding a separate step | +| `--remote` | Use the submodule's remote branch tip instead of the superproject's recorded commit | +| `--checkout` | Detached HEAD at the recorded commit (default) | +| `--rebase` | Rebase the current branch onto the recorded commit | +| `--merge` | Merge the recorded commit into the current branch | +| `--recursive` | Operate on nested submodules | +| `--jobs <n>` | Parallel clone (defaults to `submodule.fetchJobs`) | +| `-N` / `--no-fetch` | Skip the remote fetch | +| `-f` | Discard local changes in the submodule working tree | +| `--depth <n>` | Shallow clone | +| `--filter <spec>` | Partial clone filter | + +## Keep submodules pinned to the recorded commit + +```bash +git submodule update --recursive # after every git pull +git config submodule.recurse true # or do it automatically on pull/push/checkout +``` + +## Move the pin forward to the tracked branch tip + +```bash +git submodule update --remote --merge --recursive +git commit -am "chore: update submodules to latest" +``` + +`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default +branch. Commit the superproject afterwards or the new pin is lost on the next `update`. diff --git a/plugins/git/skills/git-submodules/references/sources.md b/plugins/git/skills/git-submodules/references/sources.md index 15658aa..41c8660 100644 --- a/plugins/git/skills/git-submodules/references/sources.md +++ b/plugins/git/skills/git-submodules/references/sources.md @@ -14,7 +14,9 @@ source_keys: **Contributing files:** - SKILL.md (all sections) -- references/submodules.md (all sections) +- references/setup-and-update.md (all sections) +- references/urls-and-config.md (all sections) +- references/removal.md (all sections) --- diff --git a/plugins/git/skills/git-submodules/references/submodules.md b/plugins/git/skills/git-submodules/references/submodules.md deleted file mode 100644 index ce056a7..0000000 --- a/plugins/git/skills/git-submodules/references/submodules.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -topic: submodules -source_keys: - - git-scm-submodule-docs ---- - -# Submodules — Deep Reference - -## Update flag reference - -| Flag | Meaning | -|---|---| -| `--init` | Run init first (avoids a separate step) | -| `--remote` | Use the submodule's remote branch tip instead of the superproject's recorded commit | -| `--checkout` | Detached HEAD at recorded commit (default) | -| `--rebase` | Rebase current branch onto recorded commit | -| `--merge` | Merge recorded commit into current branch | -| `--recursive` | Operate on nested submodules | -| `--jobs <n>` | Parallel clone (defaults to `submodule.fetchJobs`) | -| `-N` / `--no-fetch` | Skip remote fetch | -| `--depth <n>` | Shallow clone | -| `--filter <spec>` | Partial clone filter | - -## Workflow patterns - -### Clone a repo with submodules -```bash -git clone --recurse-submodules <url> # Git 2.13+, one step -# or -git clone <url> -git submodule update --init --recursive -``` - -### Add a dependency as a submodule -```bash -git submodule add https://github.com/org/lib.git libs/lib -git commit -m "chore: add lib as submodule" -``` - -### Keep submodules pinned to the superproject's recorded commit -```bash -git submodule update --recursive # after every git pull -git config submodule.recurse true # do this automatically on pull -``` - -### Update submodules to the latest commit on their tracked branch -```bash -git submodule update --remote --merge --recursive -git commit -am "chore: update submodules to latest" -``` - -### Override a submodule URL locally (private mirror) -```bash -git submodule init -# edit .git/config: submodule.<name>.url = <mirror-url> -git submodule update -``` -Local-only override (`.git/config`, not `.gitmodules`) — doesn't propagate to collaborators. Re-running `sync` overwrites it with the `.gitmodules` URL. - -## Removal, in full - -`deinit` alone does not remove a submodule — it only clears `.git/config` and empties the working tree. To fully remove: -```bash -git submodule deinit -f <path> # unregister from .git/config -git rm <path> # remove .gitmodules entry + gitlink from index -rm -rf .git/modules/<name>/ # stale git dir; not tracked by git, not auto-cleaned -git commit -m "chore: remove <name> submodule" -``` -`.git/modules/<name>/` persisting after `git rm` will block re-adding the same path until manually deleted. - -## Relocate an embedded `.git` directory - -```bash -git submodule absorbgitdirs [<path>...] -``` -Moves a submodule's own `.git` directory into the superproject's `.git/modules/<name>/`, linking it back with a `.git` pointer file. Needed when a submodule was created or copied without going through `git submodule add` (e.g. converting a plain nested repo into a proper submodule). - -## `foreach` shell variables - -Available inside the `<command>` argument to `git submodule foreach`: - -| Variable | Meaning | -|---|---| -| `$name` | Logical submodule name | -| `$sm_path` | Path relative to superproject root | -| `$displaypath` | Path relative to current working directory | -| `$sha1` | Recorded commit SHA | -| `$toplevel` | Superproject's root path | - -```bash -git submodule foreach --recursive '<command>' -git submodule foreach 'git pull origin main || :' # || : continues past failures -``` diff --git a/plugins/git/skills/git-submodules/references/urls-and-config.md b/plugins/git/skills/git-submodules/references/urls-and-config.md new file mode 100644 index 0000000..5d59691 --- /dev/null +++ b/plugins/git/skills/git-submodules/references/urls-and-config.md @@ -0,0 +1,79 @@ +--- +topic: submodules +source_keys: + - git-scm-submodule-docs +--- + +# Where a submodule points, and how it is configured + +## Two files, two audiences + +- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's + logical name, path, and canonical URL. +- **`.git/config`** — local only, populated by `git submodule init`. Local URL overrides live here + and never propagate to another clone. + +The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is +linked to the submodule's working tree by a `.git` pointer file. + +## `.gitmodules` keys + +| Key | Purpose | +|---|---| +| `submodule.<name>.path` | Working tree path | +| `submodule.<name>.url` | Remote URL | +| `submodule.<name>.branch` | Branch used by `update --remote` | +| `submodule.<name>.update` | Default update procedure | +| `submodule.<name>.shallow` | Recommend a shallow clone | + +## `.git/config` keys + +| Key | Purpose | +|---|---| +| `submodule.<name>.url` | Local URL override | +| `submodule.<name>.update` | Local procedure override | +| `submodule.fetchJobs` | Default parallelism for `update --jobs` | +| `submodule.recurse` | Auto-recurse submodule updates on `pull`/`push`/etc. | + +## Rebind a URL or branch + +```bash +git submodule sync --recursive # push .gitmodules URLs into .git/config +git submodule set-url <path> <url> # change the canonical URL +git submodule set-branch -b <branch> <path> # set the branch used by update --remote +``` + +Run `sync` after an upstream rename: existing clones keep the stale URL in `.git/config` until +they do. + +## Override a URL locally (private mirror) + +```bash +git submodule init +# edit .git/config: submodule.<name>.url = <mirror-url> +git submodule update +``` + +Local-only, invisible to collaborators, and overwritten by the next `sync`. + +## Relative URLs + +A `../foo.git` entry in `.gitmodules` resolves against the superproject's default remote URL, not +against the filesystem. It is portable across hosts that mirror the same layout and broken +everywhere else. + +## Custom `update` commands are security-gated + +A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by +`git submodule init`. That is deliberate: it stops a hostile clone from silently executing +arbitrary code. Setting it locally in `.git/config` is the only way to enable it. + +## Relocate an embedded `.git` directory + +```bash +git submodule absorbgitdirs [<path>...] +``` + +Moves a submodule's own `.git` directory into `.git/modules/<name>/` and leaves a `.git` pointer +file behind. Needed when a nested repository was created or copied in without going through +`git submodule add`. -- 2.43.0 From 7cb8e95379dac69eec0c900533f5e502f8fd5a36 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 20/89] refactor(git-history): retrofit to the ADR-0020 context contract Description 450 -> 266 chars, body 1044 -> 462 words. The bisect procedure moves to references/bisect.md. The boundary clause read 'not writing or rewriting commits', which disclaimed the cherry-pick and revert this skill's own Step 3 executes; it now excludes authoring and rebasing only. Adds 'backport' so cherry-pick is reachable by natural language at all, accepting 266 chars against a 250 soft target and a 400 ceiling. --- plugins/git/.apm/skills/git-history/README.md | 7 +- plugins/git/.apm/skills/git-history/SKILL.md | 113 +++++++----------- .../skills/git-history/references/README.md | 3 +- .../skills/git-history/references/bisect.md | 68 +++++++++++ .../git-history/references/git-log-format.md | 2 +- .../skills/git-history/references/sources.md | 4 +- plugins/git/skills/git-history/README.md | 7 +- plugins/git/skills/git-history/SKILL.md | 113 +++++++----------- .../skills/git-history/references/README.md | 3 +- .../skills/git-history/references/bisect.md | 68 +++++++++++ .../git-history/references/git-log-format.md | 2 +- .../skills/git-history/references/sources.md | 4 +- 12 files changed, 240 insertions(+), 154 deletions(-) create mode 100644 plugins/git/.apm/skills/git-history/references/bisect.md create mode 100644 plugins/git/skills/git-history/references/bisect.md diff --git a/plugins/git/.apm/skills/git-history/README.md b/plugins/git/.apm/skills/git-history/README.md index f37136b..72aaa3b 100644 --- a/plugins/git/.apm/skills/git-history/README.md +++ b/plugins/git/.apm/skills/git-history/README.md @@ -6,6 +6,10 @@ Inspect git history — log queries, bisect, and locating problematic commits. This skill handles history inspection within the git workflow suite. It queries logs with pickaxe/line-range/custom formats, runs bisect to find bug-introducing commits, and locates commits for downstream cherry-picking or reverting. It returns structured results for agent composition. Rebase, squash, fixup, and other history-rewriting operations are owned by git-commits, not this skill. +## Composition + +`git-branches` delegates cherry-pick and revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries those two operations rather than treating them as out of scope. They are general git knowledge, not drawn from the `history-inspection.md` research corpus. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. + ## Usage ``` @@ -19,6 +23,7 @@ Describe your history task: search logs, bisect for a regression, or locate a sp | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/git-log-format.md` | Full log format placeholders, diff-filter letters, `-L` syntax, ancestry filters, diff output-control flags | +| `references/bisect.md` | Loaded when the entry procedure is bisect: manual and automated flows, exit codes, skip, replay, narrowing, custom terms | +| `references/git-log-format.md` | Loaded when a log or diff flag needs looking up: format placeholders, presets, diff-filter letters, `-L` syntax, ancestry filters, pickaxe binary-file behaviour, diff output-control flags | | `references/sources.md` | Research sources and provenance | | `references/README.md` | Index of the references directory | diff --git a/plugins/git/.apm/skills/git-history/SKILL.md b/plugins/git/.apm/skills/git-history/SKILL.md index 9d05894..a4ab045 100644 --- a/plugins/git/.apm/skills/git-history/SKILL.md +++ b/plugins/git/.apm/skills/git-history/SKILL.md @@ -2,7 +2,10 @@ name: git-history description: > - Inspect git history: query logs with pickaxe, line-range, or custom formats; find bug origins via bisect; locate problematic commits for cherry-picking or reverting. Use when investigating history, tracing when a change happened, or finding the commit that broke something. Return structured results for downstream agents. Do not use for authoring or formatting commit messages, or executing rebase/squash/fixup operations — use git-commits for that. + Use when investigating git history — querying logs, tracing when a change + landed, bisecting the commit that broke something, or locating one to + revert or backport. Not authoring or rebasing commits -> `git-commits`. + Not history on a Gitea server -> `gitea-branches`. metadata: category: git @@ -16,81 +19,47 @@ allowed-tools: Bash ## Gotchas -- **Pickaxe searches (`-S` vs `-G`)**: `-S"string"` finds commits where string count changed; `-G"regex"` finds any line matching regex in diffs. They're not equivalent: a line replaced (one removal + one addition) matches `-G` but not `-S` if count is unchanged. -- **`--follow` only works for single files**: it traces renames but fails with multiple paths or directory globs. Use `git log -- <single-file>` or query without `--follow`. -- **Bisect with skips**: if bisect cannot pinpoint a commit because the culprit is adjacent to skipped commits, it reports "cannot find exact culprit" and lists candidates. This is not a failure — it's as precise as the skip range allows. -- **Interactive rebase is non-recoverable on mistake**: there's no undo once `rebase -i` starts. Suggest `git reflog` to recover if the user realizes mid-way they selected the wrong commits. -- **`-L` (line-range history) requires exact line numbers or regex patterns**: off-by-one errors omit the target range. Test the range with `git log -L` before offering it to users. +- `-S"string"` matches only where the string's *count* changed, so a line edited in place matches `-G"regex"` and not `-S`. Reach for `-G` whenever the string may have moved rather than appeared. +- `--follow` traces renames for exactly one path. Given several paths or a glob it fails instead of degrading, so run it once per file. +- Under `git bisect run`, exit `128` or above **aborts the session** rather than marking the commit bad, so a crashing test script ends the search silently. +- Bisect answering "cannot find exact culprit" beside skipped commits is a complete result: it is as precise as the skip range allows. -## Query Logs and Locate Commits +## Step 1 — Pick the entry procedure -Default to `git log --oneline` for quick inspection. For deeper queries: +| What is known | Procedure | +|---|---| +| Content, a file, or a line range to search for | Query the log — Step 2 | +| Nothing to search for — only that the behaviour changed between two points | Bisect — read `references/bisect.md` | +| The commit itself, already identified | Step 3 | -- **Find when a string appeared or disappeared**: Use `git log -S"string"` (count-sensitive, finds adds/removes). If you need any mention of the string in diffs, use `git log -G"regex"` instead. Add `--pickaxe-regex` to treat the `-S` string as a POSIX ERE, and `--pickaxe-all` to show every changed file in a matching changeset, not just the matching ones. Binary files are searched by `-S`; `-G` ignores them unless `--text` is also supplied. -- **Trace changes to a specific line or function**: Use `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>` (requires function name heuristic). This shows the evolution of that range across all commits. -- **Filter by change type**: Use `git log --diff-filter=<type>` (A=added, M=modified, D=deleted, R=renamed) to narrow to specific file operations. -- **Mainline-only history through merges**: Use `--first-parent` to follow only the integration branch and skip merged-in side-branch commits; combine with `--merges`/`--no-merges` or `--ancestry-path`/`--min-parents`/`--max-parents` for other ancestry-graph filtering — see `references/git-log-format.md` for the full set. -- **Custom format for structured output**: Construct format string with `%h` (hash), `%s` (subject), `%an` (author), `%ar` (relative date), `%b` (body). Example: `git log --format="%h | %s | %an (%ar)"`. -- **File-specific history with renames**: Use `git log --follow -- <file>` (single file only). Without `--follow`, log stops at the rename boundary. +## Step 2 — Query the log -## Bisect to Find Blame Commit +Default to `git log --oneline`, then narrow by whatever is known: -Use bisect when hunting for the commit that introduced a bug or behaviour change. Binary search reduces iterations from O(N) to O(log N). +- **Content**: `git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset. +- **A line or function**: `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target. +- **A file across renames**: `git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary. +- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches. +- **Structured output**: `git log --format="%h | %s | %an (%ar)"`. -**Basic manual flow:** -```bash -git bisect start -git bisect bad [HEAD] # mark current (or specified) as broken -git bisect good <commit> # mark known-good baseline -# Git checks out midpoint; test it manually -git bisect good # if test passes -git bisect bad # if test fails -# Repeat until git reports "X is the first bad commit" -git bisect reset # return to original HEAD +If you need the placeholder catalogue, format presets, `--diff-filter` letters, full `-L` syntax, ancestry filters, pickaxe binary-file behaviour, or `git diff` output-control flags such as `--stat`, `--word-diff` and the whitespace options, read `references/git-log-format.md`. + +## Step 3 — Act on a located commit + +Offer the operation and its consequence; run it only once the user has chosen. + +- `git cherry-pick <commit>` copies the commit's changes onto the current HEAD — for backporting a fix to another branch. +- `git revert <commit>` adds a new commit undoing it — for un-applying merged work without rewriting history. +- `git blame <file>` attributes each line to the commit that last touched it, when the question is which commit introduced one specific line. + +For diff output control on the located commit, read `references/git-log-format.md`. + +## Step 4 — Return the result + +Report each located commit in this shape, so a calling agent can act on it without reparsing raw log output: + +```text +<sha> — <subject> +<author> (<relative date>) +Recommendation: <action or "none"> ``` - -**Automated with `git bisect run`:** if a test command exists, use `git bisect run <cmd>`. Git interprets the exit code: `0`=good, `1-124`=bad, `125`=skip (build broken), `126-127`=POSIX shell errors treated as bad, `128+`=**aborts the bisect session entirely** (not treated as bad — a crashed test script can silently end the search). - -**With skip:** if a commit is untestable (broken build), use `git bisect skip` to exclude it without manually deciding good/bad. If the first-bad is adjacent to skips, bisect reports it cannot pinpoint but lists candidates. - -**Undoing a wrong good/bad call:** `git bisect log` prints the session's decision history; save it (`git bisect log > bisect.log`), edit out the mistaken entry, then `git bisect reset && git bisect replay bisect.log` to resume from the corrected log instead of restarting the whole search. - -**Narrowing and speeding up the search:** `git bisect start HEAD v1.2 -- src/` limits bisection to a path, cutting the number of trials. `--no-checkout` updates the `BISECT_HEAD` ref instead of checking out a working tree (useful for tests that don't need one; automatic in bare repos). `--first-parent` follows only first parents at merges, finding the integration commit that introduced a regression while ignoring broken side branches. - -**Inspecting remaining candidates visually:** `git bisect visualize` (alias `view`) opens the suspects in gitk; add `--stat` or `-p` to show diffstat or full patches instead. Falls back to `git log` when no graphical display is detected. - -**For non-regression hunts:** use `git bisect start --term-new <new> --term-old <old>` to search for a property change instead of a bug (e.g., performance regression). Then use the custom terms instead of `good`/`bad`. - -For rebase execution (interactive rebase, squash/fixup/reword, conflict handling) see git-commits — it owns history-rewriting operations. This skill only locates commits and reports on history; it does not execute rebases. - -## Find and Manipulate Problematic Commits - -Once a commit is identified (via log query or bisect), offer cherry-pick or revert. This section is general git knowledge, not sourced from `history-inspection.md` — `git-branches`'s SKILL.md explicitly delegates cherry-pick/revert here (see its Merging section), which is why this skill carries them rather than treating them as out of scope: - -- **Cherry-pick**: `git cherry-pick <commit>` copies a commit's changes onto current HEAD. Use when backporting fixes to other branches. -- **Revert**: `git revert <commit>` creates a new commit that undoes the changes. Use when un-applying a merged commit without rewriting history. -- **Blame for context**: `git blame <file>` shows which commit last changed each line. Use to trace a specific line back to its introducing commit. - -## Inspect Diffs - -Diff-output tuning is in scope too: `--stat` for a diffstat summary, `--word-diff` for word-level (not line-level) changes, and whitespace flags (`-w`, `--ignore-blank-lines`) to suppress noise from reformatting. See `references/git-log-format.md` for the full flag set. - -## Return Results Structured - -For agent consumption, return: -- **Commit SHA** (full or abbreviated as appropriate) -- **Subject line** (from `%s`) -- **Author and date** (from `%an` and `%ar`) -- **Action taken or recommended** (e.g., "Found via bisect", "Offer cherry-pick to main", "Rebase conflicts detected") - -Example for agent: -``` -Found first bad commit: abc1234 -Subject: fix null pointer in parser -Author: Alice (2 weeks ago) -Recommendation: Backport to release branch via cherry-pick -``` - -## Reference - -For the full log format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` range syntax, ancestry filters, and `git diff` output-control flags, read `references/git-log-format.md`. diff --git a/plugins/git/.apm/skills/git-history/references/README.md b/plugins/git/.apm/skills/git-history/references/README.md index 11fe328..c9a302a 100644 --- a/plugins/git/.apm/skills/git-history/references/README.md +++ b/plugins/git/.apm/skills/git-history/references/README.md @@ -12,4 +12,5 @@ This directory contains provenance metadata and research sources for the `git-hi ## Files - `sources.md` — Extracted research sources and their contributing documents -- `git-log-format.md` — Full `git log` format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` line-range syntax, ancestry filters, and `git diff` output-control flags +- `bisect.md` — The full `git bisect` procedure: manual and automated flows, exit-code semantics, skip and replay, narrowing options, and custom good/bad terms +- `git-log-format.md` — Full `git log` format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` line-range syntax, ancestry filters, pickaxe binary-file behaviour, and `git diff` output-control flags diff --git a/plugins/git/.apm/skills/git-history/references/bisect.md b/plugins/git/.apm/skills/git-history/references/bisect.md new file mode 100644 index 0000000..2fd7026 --- /dev/null +++ b/plugins/git/.apm/skills/git-history/references/bisect.md @@ -0,0 +1,68 @@ +--- +topic: bisect +source_keys: + - git-scm-bisect-docs +--- + +# Finding a commit with `git bisect` + +Read this when the question is *which commit changed the behaviour* and there is no string, file, +or line range to search the log for. Binary search reduces the trials from O(N) to O(log N). + +## Manual flow + +```bash +git bisect start +git bisect bad [HEAD] # mark current (or specified) as broken +git bisect good <commit> # mark known-good baseline +# Git checks out the midpoint; test it +git bisect good # test passes +git bisect bad # test fails +# Repeat until git reports "X is the first bad commit" +git bisect reset # return to the original HEAD +``` + +## Automated + +With a test command available, use `git bisect run <cmd>`. Git reads the exit code: `0` good, +`1`–`124` bad, `125` skip (build broken), `126`–`127` POSIX shell errors, treated as bad, and +`128` or above aborts the session outright rather than marking the commit bad. + +## Untestable commits + +`git bisect skip` excludes a commit that cannot be built or tested without deciding good or bad +for it. When the first bad commit is adjacent to a skipped range, bisect reports that it cannot +pinpoint the culprit and lists the candidates — that is the precise answer the skip range allows, +not a failure. + +## Undoing a wrong good/bad call + +`git bisect log` prints the session's decision history. Save it, edit out the mistaken entry, and +resume from the corrected log rather than restarting the search: + +```bash +git bisect log > bisect.log +# edit bisect.log, removing the wrong decision +git bisect reset && git bisect replay bisect.log +``` + +## Narrowing and speeding up + +- `git bisect start HEAD v1.2 -- src/` restricts bisection to a path, cutting the trial count. +- `--no-checkout` updates the `BISECT_HEAD` ref instead of checking out a working tree — useful + for tests that do not need one, and automatic in bare repos. +- `--first-parent` follows only first parents at merges, finding the integration commit that + introduced a regression while ignoring broken side branches. + +## Inspecting the remaining candidates + +`git bisect visualize` (alias `view`) opens the suspects in gitk, falling back to `git log` when +no graphical display is detected. Add `--stat` or `-p` for a diffstat or full patches. + +## Hunting a non-bug property change + +`git bisect start --term-new <new> --term-old <old>` searches for any property change — a +performance regression, say — instead of a bug. Use the custom terms in place of `good` and `bad` +for the rest of the session. + +Once the first bad commit is identified, return to Step 3 to act on it and Step 4 to report it. diff --git a/plugins/git/.apm/skills/git-history/references/git-log-format.md b/plugins/git/.apm/skills/git-history/references/git-log-format.md index 60e34d0..821bfdd 100644 --- a/plugins/git/.apm/skills/git-history/references/git-log-format.md +++ b/plugins/git/.apm/skills/git-history/references/git-log-format.md @@ -88,7 +88,7 @@ source_keys: | `%GK` | signing key ID | **Trailers:** -``` +```text %(trailers[:key=<k>][,only][,separator=<s>][,unfold][,keyonly][,valueonly]) ``` diff --git a/plugins/git/.apm/skills/git-history/references/sources.md b/plugins/git/.apm/skills/git-history/references/sources.md index 4b66a0f..fff5f45 100644 --- a/plugins/git/.apm/skills/git-history/references/sources.md +++ b/plugins/git/.apm/skills/git-history/references/sources.md @@ -12,7 +12,7 @@ Git bisect documentation covering binary search through commit history to find t - **Research doc:** plugins/git/docs/research/docs/git/history-inspection.md - **Doc heading:** `## git bisect` -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/bisect.md ## git-scm-log-docs @@ -28,4 +28,4 @@ Git diff documentation covering output control (--stat, --name-only, --name-stat - **Research doc:** plugins/git/docs/research/docs/git/history-inspection.md - **Doc heading:** `## git diff — Output Control` -- **Contributing files:** references/git-log-format.md +- **Contributing files:** SKILL.md, references/git-log-format.md diff --git a/plugins/git/skills/git-history/README.md b/plugins/git/skills/git-history/README.md index f37136b..72aaa3b 100644 --- a/plugins/git/skills/git-history/README.md +++ b/plugins/git/skills/git-history/README.md @@ -6,6 +6,10 @@ Inspect git history — log queries, bisect, and locating problematic commits. This skill handles history inspection within the git workflow suite. It queries logs with pickaxe/line-range/custom formats, runs bisect to find bug-introducing commits, and locates commits for downstream cherry-picking or reverting. It returns structured results for agent composition. Rebase, squash, fixup, and other history-rewriting operations are owned by git-commits, not this skill. +## Composition + +`git-branches` delegates cherry-pick and revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries those two operations rather than treating them as out of scope. They are general git knowledge, not drawn from the `history-inspection.md` research corpus. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. + ## Usage ``` @@ -19,6 +23,7 @@ Describe your history task: search logs, bisect for a regression, or locate a sp | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/git-log-format.md` | Full log format placeholders, diff-filter letters, `-L` syntax, ancestry filters, diff output-control flags | +| `references/bisect.md` | Loaded when the entry procedure is bisect: manual and automated flows, exit codes, skip, replay, narrowing, custom terms | +| `references/git-log-format.md` | Loaded when a log or diff flag needs looking up: format placeholders, presets, diff-filter letters, `-L` syntax, ancestry filters, pickaxe binary-file behaviour, diff output-control flags | | `references/sources.md` | Research sources and provenance | | `references/README.md` | Index of the references directory | diff --git a/plugins/git/skills/git-history/SKILL.md b/plugins/git/skills/git-history/SKILL.md index 9d05894..a4ab045 100644 --- a/plugins/git/skills/git-history/SKILL.md +++ b/plugins/git/skills/git-history/SKILL.md @@ -2,7 +2,10 @@ name: git-history description: > - Inspect git history: query logs with pickaxe, line-range, or custom formats; find bug origins via bisect; locate problematic commits for cherry-picking or reverting. Use when investigating history, tracing when a change happened, or finding the commit that broke something. Return structured results for downstream agents. Do not use for authoring or formatting commit messages, or executing rebase/squash/fixup operations — use git-commits for that. + Use when investigating git history — querying logs, tracing when a change + landed, bisecting the commit that broke something, or locating one to + revert or backport. Not authoring or rebasing commits -> `git-commits`. + Not history on a Gitea server -> `gitea-branches`. metadata: category: git @@ -16,81 +19,47 @@ allowed-tools: Bash ## Gotchas -- **Pickaxe searches (`-S` vs `-G`)**: `-S"string"` finds commits where string count changed; `-G"regex"` finds any line matching regex in diffs. They're not equivalent: a line replaced (one removal + one addition) matches `-G` but not `-S` if count is unchanged. -- **`--follow` only works for single files**: it traces renames but fails with multiple paths or directory globs. Use `git log -- <single-file>` or query without `--follow`. -- **Bisect with skips**: if bisect cannot pinpoint a commit because the culprit is adjacent to skipped commits, it reports "cannot find exact culprit" and lists candidates. This is not a failure — it's as precise as the skip range allows. -- **Interactive rebase is non-recoverable on mistake**: there's no undo once `rebase -i` starts. Suggest `git reflog` to recover if the user realizes mid-way they selected the wrong commits. -- **`-L` (line-range history) requires exact line numbers or regex patterns**: off-by-one errors omit the target range. Test the range with `git log -L` before offering it to users. +- `-S"string"` matches only where the string's *count* changed, so a line edited in place matches `-G"regex"` and not `-S`. Reach for `-G` whenever the string may have moved rather than appeared. +- `--follow` traces renames for exactly one path. Given several paths or a glob it fails instead of degrading, so run it once per file. +- Under `git bisect run`, exit `128` or above **aborts the session** rather than marking the commit bad, so a crashing test script ends the search silently. +- Bisect answering "cannot find exact culprit" beside skipped commits is a complete result: it is as precise as the skip range allows. -## Query Logs and Locate Commits +## Step 1 — Pick the entry procedure -Default to `git log --oneline` for quick inspection. For deeper queries: +| What is known | Procedure | +|---|---| +| Content, a file, or a line range to search for | Query the log — Step 2 | +| Nothing to search for — only that the behaviour changed between two points | Bisect — read `references/bisect.md` | +| The commit itself, already identified | Step 3 | -- **Find when a string appeared or disappeared**: Use `git log -S"string"` (count-sensitive, finds adds/removes). If you need any mention of the string in diffs, use `git log -G"regex"` instead. Add `--pickaxe-regex` to treat the `-S` string as a POSIX ERE, and `--pickaxe-all` to show every changed file in a matching changeset, not just the matching ones. Binary files are searched by `-S`; `-G` ignores them unless `--text` is also supplied. -- **Trace changes to a specific line or function**: Use `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>` (requires function name heuristic). This shows the evolution of that range across all commits. -- **Filter by change type**: Use `git log --diff-filter=<type>` (A=added, M=modified, D=deleted, R=renamed) to narrow to specific file operations. -- **Mainline-only history through merges**: Use `--first-parent` to follow only the integration branch and skip merged-in side-branch commits; combine with `--merges`/`--no-merges` or `--ancestry-path`/`--min-parents`/`--max-parents` for other ancestry-graph filtering — see `references/git-log-format.md` for the full set. -- **Custom format for structured output**: Construct format string with `%h` (hash), `%s` (subject), `%an` (author), `%ar` (relative date), `%b` (body). Example: `git log --format="%h | %s | %an (%ar)"`. -- **File-specific history with renames**: Use `git log --follow -- <file>` (single file only). Without `--follow`, log stops at the rename boundary. +## Step 2 — Query the log -## Bisect to Find Blame Commit +Default to `git log --oneline`, then narrow by whatever is known: -Use bisect when hunting for the commit that introduced a bug or behaviour change. Binary search reduces iterations from O(N) to O(log N). +- **Content**: `git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset. +- **A line or function**: `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target. +- **A file across renames**: `git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary. +- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches. +- **Structured output**: `git log --format="%h | %s | %an (%ar)"`. -**Basic manual flow:** -```bash -git bisect start -git bisect bad [HEAD] # mark current (or specified) as broken -git bisect good <commit> # mark known-good baseline -# Git checks out midpoint; test it manually -git bisect good # if test passes -git bisect bad # if test fails -# Repeat until git reports "X is the first bad commit" -git bisect reset # return to original HEAD +If you need the placeholder catalogue, format presets, `--diff-filter` letters, full `-L` syntax, ancestry filters, pickaxe binary-file behaviour, or `git diff` output-control flags such as `--stat`, `--word-diff` and the whitespace options, read `references/git-log-format.md`. + +## Step 3 — Act on a located commit + +Offer the operation and its consequence; run it only once the user has chosen. + +- `git cherry-pick <commit>` copies the commit's changes onto the current HEAD — for backporting a fix to another branch. +- `git revert <commit>` adds a new commit undoing it — for un-applying merged work without rewriting history. +- `git blame <file>` attributes each line to the commit that last touched it, when the question is which commit introduced one specific line. + +For diff output control on the located commit, read `references/git-log-format.md`. + +## Step 4 — Return the result + +Report each located commit in this shape, so a calling agent can act on it without reparsing raw log output: + +```text +<sha> — <subject> +<author> (<relative date>) +Recommendation: <action or "none"> ``` - -**Automated with `git bisect run`:** if a test command exists, use `git bisect run <cmd>`. Git interprets the exit code: `0`=good, `1-124`=bad, `125`=skip (build broken), `126-127`=POSIX shell errors treated as bad, `128+`=**aborts the bisect session entirely** (not treated as bad — a crashed test script can silently end the search). - -**With skip:** if a commit is untestable (broken build), use `git bisect skip` to exclude it without manually deciding good/bad. If the first-bad is adjacent to skips, bisect reports it cannot pinpoint but lists candidates. - -**Undoing a wrong good/bad call:** `git bisect log` prints the session's decision history; save it (`git bisect log > bisect.log`), edit out the mistaken entry, then `git bisect reset && git bisect replay bisect.log` to resume from the corrected log instead of restarting the whole search. - -**Narrowing and speeding up the search:** `git bisect start HEAD v1.2 -- src/` limits bisection to a path, cutting the number of trials. `--no-checkout` updates the `BISECT_HEAD` ref instead of checking out a working tree (useful for tests that don't need one; automatic in bare repos). `--first-parent` follows only first parents at merges, finding the integration commit that introduced a regression while ignoring broken side branches. - -**Inspecting remaining candidates visually:** `git bisect visualize` (alias `view`) opens the suspects in gitk; add `--stat` or `-p` to show diffstat or full patches instead. Falls back to `git log` when no graphical display is detected. - -**For non-regression hunts:** use `git bisect start --term-new <new> --term-old <old>` to search for a property change instead of a bug (e.g., performance regression). Then use the custom terms instead of `good`/`bad`. - -For rebase execution (interactive rebase, squash/fixup/reword, conflict handling) see git-commits — it owns history-rewriting operations. This skill only locates commits and reports on history; it does not execute rebases. - -## Find and Manipulate Problematic Commits - -Once a commit is identified (via log query or bisect), offer cherry-pick or revert. This section is general git knowledge, not sourced from `history-inspection.md` — `git-branches`'s SKILL.md explicitly delegates cherry-pick/revert here (see its Merging section), which is why this skill carries them rather than treating them as out of scope: - -- **Cherry-pick**: `git cherry-pick <commit>` copies a commit's changes onto current HEAD. Use when backporting fixes to other branches. -- **Revert**: `git revert <commit>` creates a new commit that undoes the changes. Use when un-applying a merged commit without rewriting history. -- **Blame for context**: `git blame <file>` shows which commit last changed each line. Use to trace a specific line back to its introducing commit. - -## Inspect Diffs - -Diff-output tuning is in scope too: `--stat` for a diffstat summary, `--word-diff` for word-level (not line-level) changes, and whitespace flags (`-w`, `--ignore-blank-lines`) to suppress noise from reformatting. See `references/git-log-format.md` for the full flag set. - -## Return Results Structured - -For agent consumption, return: -- **Commit SHA** (full or abbreviated as appropriate) -- **Subject line** (from `%s`) -- **Author and date** (from `%an` and `%ar`) -- **Action taken or recommended** (e.g., "Found via bisect", "Offer cherry-pick to main", "Rebase conflicts detected") - -Example for agent: -``` -Found first bad commit: abc1234 -Subject: fix null pointer in parser -Author: Alice (2 weeks ago) -Recommendation: Backport to release branch via cherry-pick -``` - -## Reference - -For the full log format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` range syntax, ancestry filters, and `git diff` output-control flags, read `references/git-log-format.md`. diff --git a/plugins/git/skills/git-history/references/README.md b/plugins/git/skills/git-history/references/README.md index 11fe328..c9a302a 100644 --- a/plugins/git/skills/git-history/references/README.md +++ b/plugins/git/skills/git-history/references/README.md @@ -12,4 +12,5 @@ This directory contains provenance metadata and research sources for the `git-hi ## Files - `sources.md` — Extracted research sources and their contributing documents -- `git-log-format.md` — Full `git log` format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` line-range syntax, ancestry filters, and `git diff` output-control flags +- `bisect.md` — The full `git bisect` procedure: manual and automated flows, exit-code semantics, skip and replay, narrowing options, and custom good/bad terms +- `git-log-format.md` — Full `git log` format placeholder catalogue, named format presets, `--diff-filter` letters, `-L` line-range syntax, ancestry filters, pickaxe binary-file behaviour, and `git diff` output-control flags diff --git a/plugins/git/skills/git-history/references/bisect.md b/plugins/git/skills/git-history/references/bisect.md new file mode 100644 index 0000000..2fd7026 --- /dev/null +++ b/plugins/git/skills/git-history/references/bisect.md @@ -0,0 +1,68 @@ +--- +topic: bisect +source_keys: + - git-scm-bisect-docs +--- + +# Finding a commit with `git bisect` + +Read this when the question is *which commit changed the behaviour* and there is no string, file, +or line range to search the log for. Binary search reduces the trials from O(N) to O(log N). + +## Manual flow + +```bash +git bisect start +git bisect bad [HEAD] # mark current (or specified) as broken +git bisect good <commit> # mark known-good baseline +# Git checks out the midpoint; test it +git bisect good # test passes +git bisect bad # test fails +# Repeat until git reports "X is the first bad commit" +git bisect reset # return to the original HEAD +``` + +## Automated + +With a test command available, use `git bisect run <cmd>`. Git reads the exit code: `0` good, +`1`–`124` bad, `125` skip (build broken), `126`–`127` POSIX shell errors, treated as bad, and +`128` or above aborts the session outright rather than marking the commit bad. + +## Untestable commits + +`git bisect skip` excludes a commit that cannot be built or tested without deciding good or bad +for it. When the first bad commit is adjacent to a skipped range, bisect reports that it cannot +pinpoint the culprit and lists the candidates — that is the precise answer the skip range allows, +not a failure. + +## Undoing a wrong good/bad call + +`git bisect log` prints the session's decision history. Save it, edit out the mistaken entry, and +resume from the corrected log rather than restarting the search: + +```bash +git bisect log > bisect.log +# edit bisect.log, removing the wrong decision +git bisect reset && git bisect replay bisect.log +``` + +## Narrowing and speeding up + +- `git bisect start HEAD v1.2 -- src/` restricts bisection to a path, cutting the trial count. +- `--no-checkout` updates the `BISECT_HEAD` ref instead of checking out a working tree — useful + for tests that do not need one, and automatic in bare repos. +- `--first-parent` follows only first parents at merges, finding the integration commit that + introduced a regression while ignoring broken side branches. + +## Inspecting the remaining candidates + +`git bisect visualize` (alias `view`) opens the suspects in gitk, falling back to `git log` when +no graphical display is detected. Add `--stat` or `-p` for a diffstat or full patches. + +## Hunting a non-bug property change + +`git bisect start --term-new <new> --term-old <old>` searches for any property change — a +performance regression, say — instead of a bug. Use the custom terms in place of `good` and `bad` +for the rest of the session. + +Once the first bad commit is identified, return to Step 3 to act on it and Step 4 to report it. diff --git a/plugins/git/skills/git-history/references/git-log-format.md b/plugins/git/skills/git-history/references/git-log-format.md index 60e34d0..821bfdd 100644 --- a/plugins/git/skills/git-history/references/git-log-format.md +++ b/plugins/git/skills/git-history/references/git-log-format.md @@ -88,7 +88,7 @@ source_keys: | `%GK` | signing key ID | **Trailers:** -``` +```text %(trailers[:key=<k>][,only][,separator=<s>][,unfold][,keyonly][,valueonly]) ``` diff --git a/plugins/git/skills/git-history/references/sources.md b/plugins/git/skills/git-history/references/sources.md index 4b66a0f..fff5f45 100644 --- a/plugins/git/skills/git-history/references/sources.md +++ b/plugins/git/skills/git-history/references/sources.md @@ -12,7 +12,7 @@ Git bisect documentation covering binary search through commit history to find t - **Research doc:** plugins/git/docs/research/docs/git/history-inspection.md - **Doc heading:** `## git bisect` -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/bisect.md ## git-scm-log-docs @@ -28,4 +28,4 @@ Git diff documentation covering output control (--stat, --name-only, --name-stat - **Research doc:** plugins/git/docs/research/docs/git/history-inspection.md - **Doc heading:** `## git diff — Output Control` -- **Contributing files:** references/git-log-format.md +- **Contributing files:** SKILL.md, references/git-log-format.md -- 2.43.0 From 15ff7417b9aced7b67e6508e948b45c25e961059 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 21/89] refactor(pc-run): retrofit to the ADR-0020 context contract Description 477 -> 211 chars, body 736 -> 367 words. Install, autoupdate, and clean become flow files behind the Route table. The audit found no route for 'hooks aren't running': the only pointer to failure-patterns.md sat inside the failure path, but when hooks never fire the manual run succeeds, so the request fell to the ambiguity default and was masked. Restores the disclosure that install -f is not reversible by uninstall. --- plugins/git/.apm/skills/pc-run/README.md | 5 +- plugins/git/.apm/skills/pc-run/SKILL.md | 108 +++--------------- .../.apm/skills/pc-run/references/README.md | 5 +- .../skills/pc-run/references/autoupdate.md | 17 +++ .../.apm/skills/pc-run/references/clean.md | 21 ++++ .../pc-run/references/failure-patterns.md | 2 + .../.apm/skills/pc-run/references/install.md | 31 +++++ .../.apm/skills/pc-run/references/sources.md | 4 +- plugins/git/skills/pc-run/README.md | 5 +- plugins/git/skills/pc-run/SKILL.md | 108 +++--------------- .../git/skills/pc-run/references/README.md | 5 +- .../skills/pc-run/references/autoupdate.md | 17 +++ plugins/git/skills/pc-run/references/clean.md | 21 ++++ .../pc-run/references/failure-patterns.md | 2 + .../git/skills/pc-run/references/install.md | 31 +++++ .../git/skills/pc-run/references/sources.md | 4 +- 16 files changed, 198 insertions(+), 188 deletions(-) create mode 100644 plugins/git/.apm/skills/pc-run/references/autoupdate.md create mode 100644 plugins/git/.apm/skills/pc-run/references/clean.md create mode 100644 plugins/git/.apm/skills/pc-run/references/install.md create mode 100644 plugins/git/skills/pc-run/references/autoupdate.md create mode 100644 plugins/git/skills/pc-run/references/clean.md create mode 100644 plugins/git/skills/pc-run/references/install.md diff --git a/plugins/git/.apm/skills/pc-run/README.md b/plugins/git/.apm/skills/pc-run/README.md index 7113b56..be4b26c 100644 --- a/plugins/git/.apm/skills/pc-run/README.md +++ b/plugins/git/.apm/skills/pc-run/README.md @@ -24,6 +24,9 @@ Common invocations: | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/failure-patterns.md` | Hook failure causes and concrete fix suggestions | +| `references/install.md` | The install flow — loaded when the user asks to install or set up hooks | +| `references/autoupdate.md` | The autoupdate flow — loaded when the user asks to bump hook revs | +| `references/clean.md` | The clean flow — loaded when the user asks to wipe the cache or rebuild environments | +| `references/failure-patterns.md` | Hook failure causes and concrete fix suggestions — loaded when a hook fails or never fires | | `references/sources.md` | Provenance: research sources that informed this skill | | `references/README.md` | Directory index for references/ | diff --git a/plugins/git/.apm/skills/pc-run/SKILL.md b/plugins/git/.apm/skills/pc-run/SKILL.md index d603638..e1da3a3 100644 --- a/plugins/git/.apm/skills/pc-run/SKILL.md +++ b/plugins/git/.apm/skills/pc-run/SKILL.md @@ -1,13 +1,9 @@ --- name: pc-run description: > - Use when the user wants to run pre-commit hooks, install git hooks, update - hook versions, or maintain the pre-commit cache. Triggers on: "run - pre-commit", "run all hooks", "check everything passes", "install hooks", - "wire hooks into git", "update hook versions", "autoupdate", "bump revs", - "clean the cache", "rebuild environments", "gc", "why is my hook failing", - "hooks aren't running". Do not use for creating or editing - `.pre-commit-config.yaml` — use `pc-author` for that. + Use when the user wants to run pre-commit hooks, wire them into git, bump hook + revs, maintain the cache, or diagnose why a hook fails or never fires. Not + creating or editing the pre-commit config -> `pc-author`. compatibility: Requires pre-commit installed and available on PATH. @@ -22,102 +18,34 @@ allowed-tools: Bash Read ## Gotchas -- Hooks not running on `git commit` almost always means `pre-commit install` was never run in this clone. Git hooks are per-clone — they are not committed to the repo. -- When a hook modifies files (e.g. `trailing-whitespace`, `end-of-file-fixer`), the commit is blocked intentionally — the staged version is stale. The fix is `git add -u && git commit`. Do NOT call `pre-commit install -f` here; that is for overwriting existing hooks, not re-staging. -- `pre-commit autoupdate` modifies `.pre-commit-config.yaml` in-place. Re-read the file after calling it to show the user the updated `rev` values. -- The `SKIP` env var requires exact hook `id` values, comma-separated, no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after the comma silently skips nothing. -- Never use `git commit --no-verify` (or `-n`) to bypass a failing hook. Hooks are the automated QA gate; bypassing them breaks the pipeline. Diagnose and fix the failure instead — see the hook-specific guidance below and in `references/failure-patterns.md`. -- A stages mismatch — hook stage not installed — means the hook was added to the config but `pre-commit install` was not re-run with the correct `-t` flags. Hooks in stages not listed under `default_install_hook_types` will never fire. +- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. +- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. ## Route -Determine intent from the user's request, then execute the matching operation: +Determine intent from the user's request, then execute the matching operation. Where the matching row names a `references/` file, read that one file and no other — each flow file is self-contained. | User intent | Operation | |---|---| | "run", "check", "verify", "test hooks" | `pre-commit run --all-files` (default) | | "staged", "simulate commit" | `pre-commit run` (staged files only) | | "CI", "changed files only", "diff range" | `pre-commit run --from-ref <base> --to-ref <head>` — prefer this over `--all-files` on large repos | -| "install", "set up hooks", "wire into git" | `pre-commit install` — see Install | -| "pre-create environments", "install-hooks", "warm cache" | `pre-commit install-hooks` — see Install | -| "remove hooks", "uninstall", "tear down pre-commit" | `pre-commit uninstall` | -| "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` | -| "gc", "garbage collect" | `pre-commit gc` | -| "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — see Clean | +| "install", "set up hooks", "wire into git" | `pre-commit install` — read `references/install.md` | +| "pre-create environments", "warm cache" | `pre-commit install-hooks` — builds every hook environment without running a hook | +| "remove hooks", "uninstall", "tear down" | `pre-commit uninstall` — removes pre-commit from `.git/hooks/` | +| "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` — read `references/autoupdate.md` | +| "gc", "garbage collect" | `pre-commit gc` — drops unused cached environments only, safe at any time | +| "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | +| "hooks aren't running", "hook never fires", "why did a hook fail" | Diagnose — read `references/failure-patterns.md` | If the intent is ambiguous, default to `pre-commit run --all-files`. ## Run -Default: `pre-commit run --all-files`. Never silently run staged-only. +Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. -```bash -pre-commit run --all-files -``` +When hooks fail: -**When hooks fail**, read the output and: -1. Identify which hook failed and the specific cause. Be concrete: "gitleaks blocked `config.json` (high-entropy string on line 12)", not just "gitleaks failed". -2. Suggest a concrete next step. Common patterns are in `references/failure-patterns.md`. -3. Do NOT auto-fix code files. Do NOT modify `.pre-commit-config.yaml`. Those are the user's or `pc-author`'s responsibility. - -If the user asks to run only staged files: `pre-commit run` (no `--all-files`). -If the user names a specific hook: `pre-commit run <hook-id>`. - -## Install - -Only run when the user explicitly asks to install or set up hooks. - -Before running, check for existing hook files: - -```bash -ls .git/hooks/ -``` - -If any hook files exist (e.g. a hand-written `pre-commit`), `pre-commit install` does NOT refuse or error — it defaults to migration mode, which runs the existing hook and pre-commit's hooks both. Only `-f` replaces the existing hook file outright, and that replacement is not reversible via `pre-commit uninstall` — uninstall only removes pre-commit from `.git/hooks/`, it does not restore whatever hand-written hook `-f` overwrote. If files are present, tell the user: "Existing hook files found at `.git/hooks/<names>`. Plain `pre-commit install` will run both; `pre-commit install -f` will overwrite them permanently instead. Proceed with plain install, or overwrite?" Wait for confirmation before using `-f`. - -```bash -pre-commit install -``` - -Re-run with `-t` flags when `default_install_hook_types` was changed or when hooks in non-default stages aren't firing: - -```bash -pre-commit install -t pre-commit -t pre-push -t commit-msg -``` - -To pre-create all hook environments without running hooks (useful for CI warm-up or first-time setup): - -```bash -pre-commit install-hooks -``` - -To remove pre-commit from `.git/hooks/` entirely: - -```bash -pre-commit uninstall -``` - -## Autoupdate - -```bash -pre-commit autoupdate -``` - -After it completes, read `.pre-commit-config.yaml` and report which `rev` values changed. If the user wants to pin to exact SHAs (for reproducibility): `pre-commit autoupdate --freeze`. - -## Clean and GC - -**`gc`** — removes only unused cached environments. Safe to run at any time: -```bash -pre-commit gc -``` - -**`clean`** — wipes the entire cache at `~/.cache/pre-commit`. All hook environments will be re-downloaded on next run. Require explicit confirmation before running: - -> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" - -Wait for the user to say yes before executing: - -```bash -pre-commit clean -``` +1. Name the hook and the specific cause. Be concrete — "gitleaks blocked `config.json` (high-entropy string on line 12)", not "gitleaks failed". +2. Suggest one concrete next step. If the cause is not obvious from the output, read `references/failure-patterns.md`. +3. Do not auto-fix code files, and do not edit `.pre-commit-config.yaml` — those belong to the user or to `pc-author`. diff --git a/plugins/git/.apm/skills/pc-run/references/README.md b/plugins/git/.apm/skills/pc-run/references/README.md index 4340626..51290f8 100644 --- a/plugins/git/.apm/skills/pc-run/references/README.md +++ b/plugins/git/.apm/skills/pc-run/references/README.md @@ -10,5 +10,8 @@ source_keys: | File | Purpose | |---|---| -| `failure-patterns.md` | Hook failure causes and concrete fix suggestions — loaded when hooks fail | +| `install.md` | The install flow — read when the user asks to install or set up hooks | +| `autoupdate.md` | The autoupdate flow — read when the user asks to bump hook revs | +| `clean.md` | The clean flow — read when the user asks to wipe the cache or rebuild environments | +| `failure-patterns.md` | Hook failure causes and concrete fix suggestions — read when a hook fails or never fires | | `sources.md` | Provenance: research sources that informed this skill | diff --git a/plugins/git/.apm/skills/pc-run/references/autoupdate.md b/plugins/git/.apm/skills/pc-run/references/autoupdate.md new file mode 100644 index 0000000..b222994 --- /dev/null +++ b/plugins/git/.apm/skills/pc-run/references/autoupdate.md @@ -0,0 +1,17 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Bumping hook revs with `autoupdate` + +Reached from `SKILL.md`'s Route table when the user asks to update hook versions or bump revs. Self-contained. + +```bash +pre-commit autoupdate +``` + +This rewrites `.pre-commit-config.yaml` in place, so re-read the file afterwards and report which `rev` values changed. It is the one operation in this skill that writes that file, and the exception is deliberate: the rewrite is pre-commit's own, resolved against the hook repos, not a hand edit — which is why `pc-author` hands rev bumps here rather than making them itself. + +Add `--freeze` when the user wants exact SHAs pinned for reproducibility. diff --git a/plugins/git/.apm/skills/pc-run/references/clean.md b/plugins/git/.apm/skills/pc-run/references/clean.md new file mode 100644 index 0000000..0ab452b --- /dev/null +++ b/plugins/git/.apm/skills/pc-run/references/clean.md @@ -0,0 +1,21 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Wiping the pre-commit cache + +Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. Self-contained. + +## Gate — confirm first + +`pre-commit clean` wipes the whole cache at `~/.cache/pre-commit`, forcing every hook environment to be re-downloaded on the next run. Require explicit confirmation before executing it: + +> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + +```bash +pre-commit clean +``` + +Prefer `pre-commit gc` when the goal is only to reclaim disk — it drops unused environments and leaves the ones in use intact, so it needs no confirmation. diff --git a/plugins/git/.apm/skills/pc-run/references/failure-patterns.md b/plugins/git/.apm/skills/pc-run/references/failure-patterns.md index efeb882..bfb3c65 100644 --- a/plugins/git/.apm/skills/pc-run/references/failure-patterns.md +++ b/plugins/git/.apm/skills/pc-run/references/failure-patterns.md @@ -18,6 +18,8 @@ git add -u git commit -m "same message" ``` +Do NOT reach for `pre-commit install -f` here. That flag overwrites existing hook files in `.git/hooks/`; it has nothing to do with re-staging. + ## Secret detected (gitleaks) > Not sourced from the pre-commit research corpus (`context7-pre-commit-com`/`pre-commit-com` cover pre-commit itself, not gitleaks) — general tool knowledge, verify against gitleaks' own docs if precision matters. diff --git a/plugins/git/.apm/skills/pc-run/references/install.md b/plugins/git/.apm/skills/pc-run/references/install.md new file mode 100644 index 0000000..e948f1b --- /dev/null +++ b/plugins/git/.apm/skills/pc-run/references/install.md @@ -0,0 +1,31 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Installing hooks into `.git/hooks/` + +Reached from `SKILL.md`'s Route table when the user asks to install or set up hooks. Self-contained. + +Only run this flow when the user explicitly asks for it. Installing rewrites their clone's `.git/hooks/`; it is never a side effect of another request. + +## Gate — existing hook files + +Check `ls .git/hooks/` first. With hook files already there, `pre-commit install` does not refuse — it silently enters migration mode and runs both. Only `-f` replaces them, and `pre-commit uninstall` cannot restore whatever `-f` overwrote. + +So when hook files are present, put the choice to the user in these terms, including the irreversibility, and wait for an answer before passing `-f`: + +> "Existing hook files found in `.git/hooks/`. Plain `pre-commit install` runs both; `-f` overwrites them permanently and `pre-commit uninstall` cannot restore them. Plain install, or overwrite?" + +## Install + +```bash +pre-commit install +``` + +Re-run with `-t` flags when `default_install_hook_types` changed, or when hooks in a non-default stage never fire — a hook whose stage was never installed cannot run: + +```bash +pre-commit install -t pre-commit -t pre-push -t commit-msg +``` diff --git a/plugins/git/.apm/skills/pc-run/references/sources.md b/plugins/git/.apm/skills/pc-run/references/sources.md index d4ad971..eb6a423 100644 --- a/plugins/git/.apm/skills/pc-run/references/sources.md +++ b/plugins/git/.apm/skills/pc-run/references/sources.md @@ -4,7 +4,7 @@ - **URL:** context7:/pre-commit/pre-commit.com - **Description:** Official pre-commit.com documentation — installation, configuration schema, CLI reference, hook authoring, advanced features, troubleshooting -- **Contributing files:** SKILL.md, references/failure-patterns.md +- **Contributing files:** SKILL.md, references/install.md, references/autoupdate.md, references/clean.md, references/failure-patterns.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,cli-reference,troubleshooting}.md - **Status:** `extracted` @@ -12,7 +12,7 @@ - **URL:** https://pre-commit.com/ - **Description:** Pre-commit framework homepage — full docs covering install, config, CLI, hook authoring, stages, local hooks, meta hooks, hazmat helpers, CI integration -- **Contributing files:** SKILL.md, references/failure-patterns.md +- **Contributing files:** SKILL.md, references/install.md, references/autoupdate.md, references/clean.md, references/failure-patterns.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,cli-reference,troubleshooting}.md - **Status:** `extracted` diff --git a/plugins/git/skills/pc-run/README.md b/plugins/git/skills/pc-run/README.md index 7113b56..be4b26c 100644 --- a/plugins/git/skills/pc-run/README.md +++ b/plugins/git/skills/pc-run/README.md @@ -24,6 +24,9 @@ Common invocations: | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/failure-patterns.md` | Hook failure causes and concrete fix suggestions | +| `references/install.md` | The install flow — loaded when the user asks to install or set up hooks | +| `references/autoupdate.md` | The autoupdate flow — loaded when the user asks to bump hook revs | +| `references/clean.md` | The clean flow — loaded when the user asks to wipe the cache or rebuild environments | +| `references/failure-patterns.md` | Hook failure causes and concrete fix suggestions — loaded when a hook fails or never fires | | `references/sources.md` | Provenance: research sources that informed this skill | | `references/README.md` | Directory index for references/ | diff --git a/plugins/git/skills/pc-run/SKILL.md b/plugins/git/skills/pc-run/SKILL.md index d603638..e1da3a3 100644 --- a/plugins/git/skills/pc-run/SKILL.md +++ b/plugins/git/skills/pc-run/SKILL.md @@ -1,13 +1,9 @@ --- name: pc-run description: > - Use when the user wants to run pre-commit hooks, install git hooks, update - hook versions, or maintain the pre-commit cache. Triggers on: "run - pre-commit", "run all hooks", "check everything passes", "install hooks", - "wire hooks into git", "update hook versions", "autoupdate", "bump revs", - "clean the cache", "rebuild environments", "gc", "why is my hook failing", - "hooks aren't running". Do not use for creating or editing - `.pre-commit-config.yaml` — use `pc-author` for that. + Use when the user wants to run pre-commit hooks, wire them into git, bump hook + revs, maintain the cache, or diagnose why a hook fails or never fires. Not + creating or editing the pre-commit config -> `pc-author`. compatibility: Requires pre-commit installed and available on PATH. @@ -22,102 +18,34 @@ allowed-tools: Bash Read ## Gotchas -- Hooks not running on `git commit` almost always means `pre-commit install` was never run in this clone. Git hooks are per-clone — they are not committed to the repo. -- When a hook modifies files (e.g. `trailing-whitespace`, `end-of-file-fixer`), the commit is blocked intentionally — the staged version is stale. The fix is `git add -u && git commit`. Do NOT call `pre-commit install -f` here; that is for overwriting existing hooks, not re-staging. -- `pre-commit autoupdate` modifies `.pre-commit-config.yaml` in-place. Re-read the file after calling it to show the user the updated `rev` values. -- The `SKIP` env var requires exact hook `id` values, comma-separated, no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after the comma silently skips nothing. -- Never use `git commit --no-verify` (or `-n`) to bypass a failing hook. Hooks are the automated QA gate; bypassing them breaks the pipeline. Diagnose and fix the failure instead — see the hook-specific guidance below and in `references/failure-patterns.md`. -- A stages mismatch — hook stage not installed — means the hook was added to the config but `pre-commit install` was not re-run with the correct `-t` flags. Hooks in stages not listed under `default_install_hook_types` will never fire. +- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. +- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. ## Route -Determine intent from the user's request, then execute the matching operation: +Determine intent from the user's request, then execute the matching operation. Where the matching row names a `references/` file, read that one file and no other — each flow file is self-contained. | User intent | Operation | |---|---| | "run", "check", "verify", "test hooks" | `pre-commit run --all-files` (default) | | "staged", "simulate commit" | `pre-commit run` (staged files only) | | "CI", "changed files only", "diff range" | `pre-commit run --from-ref <base> --to-ref <head>` — prefer this over `--all-files` on large repos | -| "install", "set up hooks", "wire into git" | `pre-commit install` — see Install | -| "pre-create environments", "install-hooks", "warm cache" | `pre-commit install-hooks` — see Install | -| "remove hooks", "uninstall", "tear down pre-commit" | `pre-commit uninstall` | -| "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` | -| "gc", "garbage collect" | `pre-commit gc` | -| "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — see Clean | +| "install", "set up hooks", "wire into git" | `pre-commit install` — read `references/install.md` | +| "pre-create environments", "warm cache" | `pre-commit install-hooks` — builds every hook environment without running a hook | +| "remove hooks", "uninstall", "tear down" | `pre-commit uninstall` — removes pre-commit from `.git/hooks/` | +| "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` — read `references/autoupdate.md` | +| "gc", "garbage collect" | `pre-commit gc` — drops unused cached environments only, safe at any time | +| "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | +| "hooks aren't running", "hook never fires", "why did a hook fail" | Diagnose — read `references/failure-patterns.md` | If the intent is ambiguous, default to `pre-commit run --all-files`. ## Run -Default: `pre-commit run --all-files`. Never silently run staged-only. +Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. -```bash -pre-commit run --all-files -``` +When hooks fail: -**When hooks fail**, read the output and: -1. Identify which hook failed and the specific cause. Be concrete: "gitleaks blocked `config.json` (high-entropy string on line 12)", not just "gitleaks failed". -2. Suggest a concrete next step. Common patterns are in `references/failure-patterns.md`. -3. Do NOT auto-fix code files. Do NOT modify `.pre-commit-config.yaml`. Those are the user's or `pc-author`'s responsibility. - -If the user asks to run only staged files: `pre-commit run` (no `--all-files`). -If the user names a specific hook: `pre-commit run <hook-id>`. - -## Install - -Only run when the user explicitly asks to install or set up hooks. - -Before running, check for existing hook files: - -```bash -ls .git/hooks/ -``` - -If any hook files exist (e.g. a hand-written `pre-commit`), `pre-commit install` does NOT refuse or error — it defaults to migration mode, which runs the existing hook and pre-commit's hooks both. Only `-f` replaces the existing hook file outright, and that replacement is not reversible via `pre-commit uninstall` — uninstall only removes pre-commit from `.git/hooks/`, it does not restore whatever hand-written hook `-f` overwrote. If files are present, tell the user: "Existing hook files found at `.git/hooks/<names>`. Plain `pre-commit install` will run both; `pre-commit install -f` will overwrite them permanently instead. Proceed with plain install, or overwrite?" Wait for confirmation before using `-f`. - -```bash -pre-commit install -``` - -Re-run with `-t` flags when `default_install_hook_types` was changed or when hooks in non-default stages aren't firing: - -```bash -pre-commit install -t pre-commit -t pre-push -t commit-msg -``` - -To pre-create all hook environments without running hooks (useful for CI warm-up or first-time setup): - -```bash -pre-commit install-hooks -``` - -To remove pre-commit from `.git/hooks/` entirely: - -```bash -pre-commit uninstall -``` - -## Autoupdate - -```bash -pre-commit autoupdate -``` - -After it completes, read `.pre-commit-config.yaml` and report which `rev` values changed. If the user wants to pin to exact SHAs (for reproducibility): `pre-commit autoupdate --freeze`. - -## Clean and GC - -**`gc`** — removes only unused cached environments. Safe to run at any time: -```bash -pre-commit gc -``` - -**`clean`** — wipes the entire cache at `~/.cache/pre-commit`. All hook environments will be re-downloaded on next run. Require explicit confirmation before running: - -> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" - -Wait for the user to say yes before executing: - -```bash -pre-commit clean -``` +1. Name the hook and the specific cause. Be concrete — "gitleaks blocked `config.json` (high-entropy string on line 12)", not "gitleaks failed". +2. Suggest one concrete next step. If the cause is not obvious from the output, read `references/failure-patterns.md`. +3. Do not auto-fix code files, and do not edit `.pre-commit-config.yaml` — those belong to the user or to `pc-author`. diff --git a/plugins/git/skills/pc-run/references/README.md b/plugins/git/skills/pc-run/references/README.md index 4340626..51290f8 100644 --- a/plugins/git/skills/pc-run/references/README.md +++ b/plugins/git/skills/pc-run/references/README.md @@ -10,5 +10,8 @@ source_keys: | File | Purpose | |---|---| -| `failure-patterns.md` | Hook failure causes and concrete fix suggestions — loaded when hooks fail | +| `install.md` | The install flow — read when the user asks to install or set up hooks | +| `autoupdate.md` | The autoupdate flow — read when the user asks to bump hook revs | +| `clean.md` | The clean flow — read when the user asks to wipe the cache or rebuild environments | +| `failure-patterns.md` | Hook failure causes and concrete fix suggestions — read when a hook fails or never fires | | `sources.md` | Provenance: research sources that informed this skill | diff --git a/plugins/git/skills/pc-run/references/autoupdate.md b/plugins/git/skills/pc-run/references/autoupdate.md new file mode 100644 index 0000000..b222994 --- /dev/null +++ b/plugins/git/skills/pc-run/references/autoupdate.md @@ -0,0 +1,17 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Bumping hook revs with `autoupdate` + +Reached from `SKILL.md`'s Route table when the user asks to update hook versions or bump revs. Self-contained. + +```bash +pre-commit autoupdate +``` + +This rewrites `.pre-commit-config.yaml` in place, so re-read the file afterwards and report which `rev` values changed. It is the one operation in this skill that writes that file, and the exception is deliberate: the rewrite is pre-commit's own, resolved against the hook repos, not a hand edit — which is why `pc-author` hands rev bumps here rather than making them itself. + +Add `--freeze` when the user wants exact SHAs pinned for reproducibility. diff --git a/plugins/git/skills/pc-run/references/clean.md b/plugins/git/skills/pc-run/references/clean.md new file mode 100644 index 0000000..0ab452b --- /dev/null +++ b/plugins/git/skills/pc-run/references/clean.md @@ -0,0 +1,21 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Wiping the pre-commit cache + +Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. Self-contained. + +## Gate — confirm first + +`pre-commit clean` wipes the whole cache at `~/.cache/pre-commit`, forcing every hook environment to be re-downloaded on the next run. Require explicit confirmation before executing it: + +> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + +```bash +pre-commit clean +``` + +Prefer `pre-commit gc` when the goal is only to reclaim disk — it drops unused environments and leaves the ones in use intact, so it needs no confirmation. diff --git a/plugins/git/skills/pc-run/references/failure-patterns.md b/plugins/git/skills/pc-run/references/failure-patterns.md index efeb882..bfb3c65 100644 --- a/plugins/git/skills/pc-run/references/failure-patterns.md +++ b/plugins/git/skills/pc-run/references/failure-patterns.md @@ -18,6 +18,8 @@ git add -u git commit -m "same message" ``` +Do NOT reach for `pre-commit install -f` here. That flag overwrites existing hook files in `.git/hooks/`; it has nothing to do with re-staging. + ## Secret detected (gitleaks) > Not sourced from the pre-commit research corpus (`context7-pre-commit-com`/`pre-commit-com` cover pre-commit itself, not gitleaks) — general tool knowledge, verify against gitleaks' own docs if precision matters. diff --git a/plugins/git/skills/pc-run/references/install.md b/plugins/git/skills/pc-run/references/install.md new file mode 100644 index 0000000..e948f1b --- /dev/null +++ b/plugins/git/skills/pc-run/references/install.md @@ -0,0 +1,31 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Installing hooks into `.git/hooks/` + +Reached from `SKILL.md`'s Route table when the user asks to install or set up hooks. Self-contained. + +Only run this flow when the user explicitly asks for it. Installing rewrites their clone's `.git/hooks/`; it is never a side effect of another request. + +## Gate — existing hook files + +Check `ls .git/hooks/` first. With hook files already there, `pre-commit install` does not refuse — it silently enters migration mode and runs both. Only `-f` replaces them, and `pre-commit uninstall` cannot restore whatever `-f` overwrote. + +So when hook files are present, put the choice to the user in these terms, including the irreversibility, and wait for an answer before passing `-f`: + +> "Existing hook files found in `.git/hooks/`. Plain `pre-commit install` runs both; `-f` overwrites them permanently and `pre-commit uninstall` cannot restore them. Plain install, or overwrite?" + +## Install + +```bash +pre-commit install +``` + +Re-run with `-t` flags when `default_install_hook_types` changed, or when hooks in a non-default stage never fire — a hook whose stage was never installed cannot run: + +```bash +pre-commit install -t pre-commit -t pre-push -t commit-msg +``` diff --git a/plugins/git/skills/pc-run/references/sources.md b/plugins/git/skills/pc-run/references/sources.md index d4ad971..eb6a423 100644 --- a/plugins/git/skills/pc-run/references/sources.md +++ b/plugins/git/skills/pc-run/references/sources.md @@ -4,7 +4,7 @@ - **URL:** context7:/pre-commit/pre-commit.com - **Description:** Official pre-commit.com documentation — installation, configuration schema, CLI reference, hook authoring, advanced features, troubleshooting -- **Contributing files:** SKILL.md, references/failure-patterns.md +- **Contributing files:** SKILL.md, references/install.md, references/autoupdate.md, references/clean.md, references/failure-patterns.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,cli-reference,troubleshooting}.md - **Status:** `extracted` @@ -12,7 +12,7 @@ - **URL:** https://pre-commit.com/ - **Description:** Pre-commit framework homepage — full docs covering install, config, CLI, hook authoring, stages, local hooks, meta hooks, hazmat helpers, CI integration -- **Contributing files:** SKILL.md, references/failure-patterns.md +- **Contributing files:** SKILL.md, references/install.md, references/autoupdate.md, references/clean.md, references/failure-patterns.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,cli-reference,troubleshooting}.md - **Status:** `extracted` -- 2.43.0 From 3cd3f33706e4557a98dd3d4e790ea0a0bb264e6e Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:10:53 +0000 Subject: [PATCH 22/89] refactor(pc-author): retrofit to the ADR-0020 context contract Description 475 -> 213 chars, body 680 -> 212 words. Create and modify become self-contained flow files behind a dispatch table, since the two are mutually exclusive on whether the config already exists. Passed its clean-context audit with no must-fix findings. --- plugins/git/.apm/skills/pc-author/README.md | 2 + plugins/git/.apm/skills/pc-author/SKILL.md | 84 ++++--------------- .../skills/pc-author/references/README.md | 2 + .../pc-author/references/create-config.md | 31 +++++++ .../pc-author/references/hooks-by-language.md | 10 ++- .../pc-author/references/modify-config.md | 75 +++++++++++++++++ .../skills/pc-author/references/sources.md | 4 +- plugins/git/skills/pc-author/README.md | 2 + plugins/git/skills/pc-author/SKILL.md | 84 ++++--------------- .../git/skills/pc-author/references/README.md | 2 + .../pc-author/references/create-config.md | 31 +++++++ .../pc-author/references/hooks-by-language.md | 10 ++- .../pc-author/references/modify-config.md | 75 +++++++++++++++++ .../skills/pc-author/references/sources.md | 4 +- 14 files changed, 272 insertions(+), 144 deletions(-) create mode 100644 plugins/git/.apm/skills/pc-author/references/create-config.md create mode 100644 plugins/git/.apm/skills/pc-author/references/modify-config.md create mode 100644 plugins/git/skills/pc-author/references/create-config.md create mode 100644 plugins/git/skills/pc-author/references/modify-config.md diff --git a/plugins/git/.apm/skills/pc-author/README.md b/plugins/git/.apm/skills/pc-author/README.md index 3c2722a..61de70f 100644 --- a/plugins/git/.apm/skills/pc-author/README.md +++ b/plugins/git/.apm/skills/pc-author/README.md @@ -19,6 +19,8 @@ Invoke with no arguments. The skill determines from context whether to create a | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/create-config.md` | Loaded when the repo has no `.pre-commit-config.yaml` — the create-from-scratch flow | +| `references/modify-config.md` | Loaded when a `.pre-commit-config.yaml` already exists — add, remove, top-level keys, rev staleness | | `references/hooks-by-language.md` | Hook recommendations by detected language/extension | | `references/README.md` | Index of files in references/ | | `references/sources.md` | Provenance — research sources that informed this skill | diff --git a/plugins/git/.apm/skills/pc-author/SKILL.md b/plugins/git/.apm/skills/pc-author/SKILL.md index 3bcad4a..b1d1db8 100644 --- a/plugins/git/.apm/skills/pc-author/SKILL.md +++ b/plugins/git/.apm/skills/pc-author/SKILL.md @@ -1,13 +1,9 @@ --- name: pc-author description: > - Use when the user wants to create, add hooks to, remove hooks from, update, - or configure .pre-commit-config.yaml. Triggers on: "set up pre-commit", - "add a hook", "remove this hook", "configure pre-commit", "create a pre-commit - config", "disable trailing whitespace hook", "add shellcheck", "update my - pre-commit config", even if the user does not name pre-commit explicitly. - Do not use for running hooks, installing git hooks, or bumping revision pins - — use pc-run for those. + Use when the user wants to create or edit `.pre-commit-config.yaml` — add, + remove, or configure hooks — even when they name only the tool ("add + shellcheck"). Not running, installing, or updating hooks -> `pc-run`. allowed-tools: Bash Read Write Edit metadata: category: devtools @@ -20,72 +16,22 @@ metadata: ## Gotchas -- `rev` must be an immutable tag or commit SHA — never a branch name. `pre-commit autoupdate` breaks silently on branches. -- Fixers (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json`) modify files but do NOT auto-stage them. The commit is blocked; the user must re-stage and recommit. Warn when adding fixers. -- `pre-commit validate-config` catches YAML structure errors but does NOT check whether hook `id`s exist in the target repo's manifest, and does NOT download or run hooks. It is fast; run it after every write. -- When removing a hook leaves its repo block with zero hooks, delete the entire repo block — an empty `hooks: []` causes `validate-config` to fail. -- `language: system` and `language: script` are deprecated names. Use `language: unsupported` and `language: unsupported_script` for new local hooks. +- `rev` must be an immutable tag or commit SHA, never a branch name. A branch looks like it works and then breaks `pre-commit autoupdate` silently. +- `pre-commit validate-config` checks YAML structure only — it never confirms a hook `id` exists upstream, so a config it accepts can still fail on first use. ## Route -Check before acting: +| Condition | Flow | Read | +|---|---|---| +| No `.pre-commit-config.yaml` in the repo | Create | `references/create-config.md` | +| `.pre-commit-config.yaml` exists | Modify | `references/modify-config.md` | -- `.pre-commit-config.yaml` does not exist → **Create from scratch** -- File exists → **Modify existing** +Read only the file matching the resolved flow — each is self-contained. -## Create from scratch +The target is always `.pre-commit-config.yaml`, the config that consumes hooks. A request to publish hooks for other repos to consume means `.pre-commit-hooks.yaml`, a different file this skill does not author. -1. Run a shallow extension scan: - ```bash - git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn - ``` -2. Read `references/hooks-by-language.md` to map detected extensions to recommended hooks. For a minimal starting point instead of a full recommendation set, `pre-commit sample-config > .pre-commit-config.yaml` prints a small starter config to build on. -3. State the proposed config in full before writing. Wait for user confirmation. -4. Write `.pre-commit-config.yaml`. -5. Run `pre-commit validate-config`. If non-zero: show the error, fix it, re-validate. Never leave a broken config. +## Gates common to both flows -## Modify existing - -Read `.pre-commit-config.yaml` first. Note any stale `rev` values (see **Rev staleness** below) but do not change them. - -### Adding a hook - -1. Run a shallow extension scan to detect languages in the repo: - ```bash - git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn - ``` -2. Read `references/hooks-by-language.md` for the correct repo URL, rev, and recommended args for any hook before writing. -3. Check for duplicates — if the same hook ID or equivalent tool already exists in the config, say so and stop. -4. To sanity-check a hook against the repo's actual files before committing to it in config, smoke-test it with `pre-commit try-repo <repo-url> <hook-id> --verbose` (or a local path for hooks under development). This runs the hook without writing anything. -5. If the hook's source repo already exists in the config, add the hook under that repo block. Otherwise append a new repo block. -6. State the proposed addition. Wait for confirmation. -7. Write. Run `pre-commit validate-config`. If non-zero: show error, fix, re-validate. - -### Removing a hook - -1. Identify the hook entry and its repo block. -2. State what will be removed: hook ID, and whether the parent repo block will also be deleted (if it would have zero hooks remaining). Wait for confirmation. -3. Remove the hook entry. If the repo block now has zero hooks remaining, remove the entire repo block. -4. Write. Run `pre-commit validate-config`. If non-zero: revert the edit, show the error, and stop — do not leave a broken config (removal edits are not safely auto-fixable, unlike a bad new hook block, which can usually be corrected in place). - -### Configuring top-level keys - -Only when the user explicitly asks. Valid keys: `fail_fast`, `default_stages`, `default_language_version`, `minimum_pre_commit_version`, `exclude`, `files`, `default_install_hook_types`. - -State the proposed change and wait for confirmation before writing. - -## Rev staleness - -When reading the config, for each repo listed in `references/hooks-by-language.md`, compare its `rev` in the user's config against the rev in that file. Flag any mismatch as potentially outdated and tell the user to run `pc-run` to autoupdate. Repos not in the reference cannot be checked — skip them silently. Do not modify `rev` values yourself. - -The reference table's pins can themselves go stale between updates — treat a mismatch as a prompt to check, not a certainty. `pre-commit autoupdate` (via `pc-run`) is the authoritative source for what the current rev actually is. - -## Scope boundary - -This skill manages `.pre-commit-config.yaml` only. It does not: -- Author `.pre-commit-hooks.yaml` (publishing hooks for external consumers) -- Run `pre-commit install` -- Execute hooks or run the test suite -- Bump `rev` values - -For those operations, use `pc-run`. +1. State the proposed config or edit in full and wait for confirmation before writing. Hook choices are opinions imposed on everyone else's commit loop, not defaults to assume. +2. Run `pre-commit validate-config` after every write. On a non-zero exit, show the error and resolve it before reporting done — never leave a config that cannot be parsed. +3. Never edit a `rev` value. Report staleness and hand the bump to `pc-run`, which runs `autoupdate` against the hook repos themselves. diff --git a/plugins/git/.apm/skills/pc-author/references/README.md b/plugins/git/.apm/skills/pc-author/references/README.md index ba0a6c0..7bc855a 100644 --- a/plugins/git/.apm/skills/pc-author/references/README.md +++ b/plugins/git/.apm/skills/pc-author/references/README.md @@ -10,5 +10,7 @@ source_keys: | File | Purpose | |---|---| +| `create-config.md` | The create flow — read when the repo has no `.pre-commit-config.yaml` | +| `modify-config.md` | The modify flow — read when a `.pre-commit-config.yaml` already exists | | `hooks-by-language.md` | Hook recommendations by language/context — repo, rev, and rationale for adding hooks | | `sources.md` | Provenance: research sources that informed this skill | diff --git a/plugins/git/.apm/skills/pc-author/references/create-config.md b/plugins/git/.apm/skills/pc-author/references/create-config.md new file mode 100644 index 0000000..8adfdfb --- /dev/null +++ b/plugins/git/.apm/skills/pc-author/references/create-config.md @@ -0,0 +1,31 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Creating a `.pre-commit-config.yaml` + +Reached from `SKILL.md`'s Route table when the repo has no config yet. Self-contained — the modify +flow's file is not needed here. `SKILL.md`'s three common gates still apply. + +## Steps + +1. Detect what languages are actually in the repo with a shallow extension scan, rather than + inferring them from the project's name or README: + + ```bash + git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn + ``` + +2. Read `references/hooks-by-language.md` and map the detected extensions to recommended hooks. + Take the repo URL, `rev` and args from that file rather than from memory — a `rev` that does not + exist is the most common way a fresh config fails on its first run. + + For a deliberately minimal starting point instead of a full recommendation set, + `pre-commit sample-config > .pre-commit-config.yaml` prints a small starter config to build on. + +3. State the proposed config in full and wait for the user's confirmation. + +4. Write `.pre-commit-config.yaml`, then run `pre-commit validate-config`. If it exits non-zero, + show the error, fix it in place, and re-validate. diff --git a/plugins/git/.apm/skills/pc-author/references/hooks-by-language.md b/plugins/git/.apm/skills/pc-author/references/hooks-by-language.md index 8d5a382..ab8aabe 100644 --- a/plugins/git/.apm/skills/pc-author/references/hooks-by-language.md +++ b/plugins/git/.apm/skills/pc-author/references/hooks-by-language.md @@ -11,6 +11,11 @@ source_keys: Use this table when creating a config from scratch or recommending hooks to add. Always check the existing config for duplicates before proposing. +Fixer hooks (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json` and the like) rewrite +files but do NOT re-stage them, so the commit is still blocked and the user has to stage and commit +again. Say so when proposing one — otherwise the first blocked commit reads as the hook being +broken. + ## Universal (recommend for every repo) | Hook ID | Repo | Rev | Rationale | @@ -101,6 +106,9 @@ Use for repo-specific scripts that don't belong in an external hook repo. stages: [pre-push] ``` +`language: system` and `language: script` are deprecated names for the first two below. +New local hooks use `unsupported` and `unsupported_script`. + Language choices for local hooks: - `unsupported` — system PATH tool (pre-commit does not manage env) - `unsupported_script` — script at a repo-relative path @@ -117,4 +125,4 @@ Language choices for local hooks: ## Rev pin freshness -The revs above were last verified current at time of writing (matched against the plugin's own research corpus in `docs/research/docs/pre-commit/`; rows marked "Unverified" have no such backing and must be checked against upstream before use). Since `pc-author`'s "Rev staleness" check treats this table as ground truth, a pin that goes stale here produces false-positive staleness warnings for users who already have a newer, correct rev. Re-verify these pins periodically (e.g. against each repo's latest release tag). When in doubt, treat `pre-commit autoupdate`'s own output as the authoritative staleness signal, not a mismatch against this table. +The revs above were last verified current at time of writing (matched against the plugin's own research corpus in `docs/research/docs/pre-commit/`; rows marked "Unverified" have no such backing and must be checked against upstream before use). Since the "Rev staleness" check in `references/modify-config.md` treats this table as ground truth, a pin that goes stale here produces false-positive staleness warnings for users who already have a newer, correct rev. Re-verify these pins periodically (e.g. against each repo's latest release tag). When in doubt, treat `pre-commit autoupdate`'s own output as the authoritative staleness signal, not a mismatch against this table. diff --git a/plugins/git/.apm/skills/pc-author/references/modify-config.md b/plugins/git/.apm/skills/pc-author/references/modify-config.md new file mode 100644 index 0000000..83a0ae6 --- /dev/null +++ b/plugins/git/.apm/skills/pc-author/references/modify-config.md @@ -0,0 +1,75 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Modifying an existing `.pre-commit-config.yaml` + +Reached from `SKILL.md`'s Route table when the repo already has a config. Self-contained — the +create flow's file is not needed here. `SKILL.md`'s three common gates still apply. + +Read the existing `.pre-commit-config.yaml` before editing. Note any stale `rev` values (see +**Rev staleness** below) but do not change them. + +## Adding a hook + +1. Run a shallow extension scan, so the addition is judged against the languages actually present: + + ```bash + git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn + ``` + +2. Read `references/hooks-by-language.md` for the correct repo URL, `rev` and recommended args + before writing anything. + +3. Check for duplicates. If the same hook ID, or an equivalent tool, is already configured, say so + and stop rather than adding a second one. + +4. To sanity-check a hook against the repo's real files before committing to it, smoke-test it: + + ```bash + pre-commit try-repo <repo-url> <hook-id> --verbose + ``` + + Use a local path in place of the URL for a hook under development. This runs the hook without + writing anything. + +5. If the hook's source repo is already a block in the config, add the hook under that block. + Otherwise append a new repo block. + +6. State the proposed addition, wait for confirmation, write, and run `pre-commit validate-config`. + On a non-zero exit, show the error, fix it, and re-validate. + +## Removing a hook + +1. Identify the hook entry and its parent repo block. + +2. State what will be removed — the hook ID, and whether the parent repo block goes with it because + it would be left with zero hooks. Wait for confirmation. + +3. Remove the hook entry. If the repo block now has no hooks left, remove the whole block: an empty + `hooks: []` fails `validate-config`. + +4. Write, then run `pre-commit validate-config`. On a non-zero exit, **revert the edit**, show the + error, and stop. A removal is not safely fixable in place the way a malformed new hook block is, + so recovering the prior state beats patching forward. + +## Configuring top-level keys + +Only when the user explicitly asks. Valid keys: `fail_fast`, `default_stages`, +`default_language_version`, `minimum_pre_commit_version`, `exclude`, `files`, +`default_install_hook_types`. + +State the proposed change and wait for confirmation before writing. + +## Rev staleness + +For each repo in the config that also appears in `references/hooks-by-language.md`, compare the +two `rev` values. Flag a mismatch as potentially outdated and tell the user to run `pc-run` to +autoupdate. Repos absent from the reference cannot be checked — skip them silently. Do not modify +any `rev` yourself. + +The reference table's own pins go stale between updates, so treat a mismatch as a prompt to check +rather than proof of staleness. `pre-commit autoupdate`, via `pc-run`, is the authoritative answer +to what the current `rev` actually is. diff --git a/plugins/git/.apm/skills/pc-author/references/sources.md b/plugins/git/.apm/skills/pc-author/references/sources.md index 651e610..24f21a3 100644 --- a/plugins/git/.apm/skills/pc-author/references/sources.md +++ b/plugins/git/.apm/skills/pc-author/references/sources.md @@ -4,7 +4,7 @@ - **URL:** context7:/pre-commit/pre-commit.com - **Description:** Official pre-commit.com documentation — installation, configuration schema, CLI reference, hook authoring, advanced features, troubleshooting -- **Contributing files:** SKILL.md, references/hooks-by-language.md +- **Contributing files:** SKILL.md, references/create-config.md, references/modify-config.md, references/hooks-by-language.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,configuration,cli-reference,hook-authoring}.md - **Status:** `extracted` @@ -12,7 +12,7 @@ - **URL:** https://pre-commit.com/ - **Description:** Pre-commit framework homepage — full docs covering install, config, CLI, hook authoring, stages, local hooks, meta hooks, hazmat helpers, CI integration -- **Contributing files:** SKILL.md, references/hooks-by-language.md +- **Contributing files:** SKILL.md, references/create-config.md, references/modify-config.md, references/hooks-by-language.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,configuration,cli-reference,hook-authoring}.md - **Status:** `extracted` diff --git a/plugins/git/skills/pc-author/README.md b/plugins/git/skills/pc-author/README.md index 3c2722a..61de70f 100644 --- a/plugins/git/skills/pc-author/README.md +++ b/plugins/git/skills/pc-author/README.md @@ -19,6 +19,8 @@ Invoke with no arguments. The skill determines from context whether to create a | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/create-config.md` | Loaded when the repo has no `.pre-commit-config.yaml` — the create-from-scratch flow | +| `references/modify-config.md` | Loaded when a `.pre-commit-config.yaml` already exists — add, remove, top-level keys, rev staleness | | `references/hooks-by-language.md` | Hook recommendations by detected language/extension | | `references/README.md` | Index of files in references/ | | `references/sources.md` | Provenance — research sources that informed this skill | diff --git a/plugins/git/skills/pc-author/SKILL.md b/plugins/git/skills/pc-author/SKILL.md index 3bcad4a..b1d1db8 100644 --- a/plugins/git/skills/pc-author/SKILL.md +++ b/plugins/git/skills/pc-author/SKILL.md @@ -1,13 +1,9 @@ --- name: pc-author description: > - Use when the user wants to create, add hooks to, remove hooks from, update, - or configure .pre-commit-config.yaml. Triggers on: "set up pre-commit", - "add a hook", "remove this hook", "configure pre-commit", "create a pre-commit - config", "disable trailing whitespace hook", "add shellcheck", "update my - pre-commit config", even if the user does not name pre-commit explicitly. - Do not use for running hooks, installing git hooks, or bumping revision pins - — use pc-run for those. + Use when the user wants to create or edit `.pre-commit-config.yaml` — add, + remove, or configure hooks — even when they name only the tool ("add + shellcheck"). Not running, installing, or updating hooks -> `pc-run`. allowed-tools: Bash Read Write Edit metadata: category: devtools @@ -20,72 +16,22 @@ metadata: ## Gotchas -- `rev` must be an immutable tag or commit SHA — never a branch name. `pre-commit autoupdate` breaks silently on branches. -- Fixers (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json`) modify files but do NOT auto-stage them. The commit is blocked; the user must re-stage and recommit. Warn when adding fixers. -- `pre-commit validate-config` catches YAML structure errors but does NOT check whether hook `id`s exist in the target repo's manifest, and does NOT download or run hooks. It is fast; run it after every write. -- When removing a hook leaves its repo block with zero hooks, delete the entire repo block — an empty `hooks: []` causes `validate-config` to fail. -- `language: system` and `language: script` are deprecated names. Use `language: unsupported` and `language: unsupported_script` for new local hooks. +- `rev` must be an immutable tag or commit SHA, never a branch name. A branch looks like it works and then breaks `pre-commit autoupdate` silently. +- `pre-commit validate-config` checks YAML structure only — it never confirms a hook `id` exists upstream, so a config it accepts can still fail on first use. ## Route -Check before acting: +| Condition | Flow | Read | +|---|---|---| +| No `.pre-commit-config.yaml` in the repo | Create | `references/create-config.md` | +| `.pre-commit-config.yaml` exists | Modify | `references/modify-config.md` | -- `.pre-commit-config.yaml` does not exist → **Create from scratch** -- File exists → **Modify existing** +Read only the file matching the resolved flow — each is self-contained. -## Create from scratch +The target is always `.pre-commit-config.yaml`, the config that consumes hooks. A request to publish hooks for other repos to consume means `.pre-commit-hooks.yaml`, a different file this skill does not author. -1. Run a shallow extension scan: - ```bash - git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn - ``` -2. Read `references/hooks-by-language.md` to map detected extensions to recommended hooks. For a minimal starting point instead of a full recommendation set, `pre-commit sample-config > .pre-commit-config.yaml` prints a small starter config to build on. -3. State the proposed config in full before writing. Wait for user confirmation. -4. Write `.pre-commit-config.yaml`. -5. Run `pre-commit validate-config`. If non-zero: show the error, fix it, re-validate. Never leave a broken config. +## Gates common to both flows -## Modify existing - -Read `.pre-commit-config.yaml` first. Note any stale `rev` values (see **Rev staleness** below) but do not change them. - -### Adding a hook - -1. Run a shallow extension scan to detect languages in the repo: - ```bash - git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn - ``` -2. Read `references/hooks-by-language.md` for the correct repo URL, rev, and recommended args for any hook before writing. -3. Check for duplicates — if the same hook ID or equivalent tool already exists in the config, say so and stop. -4. To sanity-check a hook against the repo's actual files before committing to it in config, smoke-test it with `pre-commit try-repo <repo-url> <hook-id> --verbose` (or a local path for hooks under development). This runs the hook without writing anything. -5. If the hook's source repo already exists in the config, add the hook under that repo block. Otherwise append a new repo block. -6. State the proposed addition. Wait for confirmation. -7. Write. Run `pre-commit validate-config`. If non-zero: show error, fix, re-validate. - -### Removing a hook - -1. Identify the hook entry and its repo block. -2. State what will be removed: hook ID, and whether the parent repo block will also be deleted (if it would have zero hooks remaining). Wait for confirmation. -3. Remove the hook entry. If the repo block now has zero hooks remaining, remove the entire repo block. -4. Write. Run `pre-commit validate-config`. If non-zero: revert the edit, show the error, and stop — do not leave a broken config (removal edits are not safely auto-fixable, unlike a bad new hook block, which can usually be corrected in place). - -### Configuring top-level keys - -Only when the user explicitly asks. Valid keys: `fail_fast`, `default_stages`, `default_language_version`, `minimum_pre_commit_version`, `exclude`, `files`, `default_install_hook_types`. - -State the proposed change and wait for confirmation before writing. - -## Rev staleness - -When reading the config, for each repo listed in `references/hooks-by-language.md`, compare its `rev` in the user's config against the rev in that file. Flag any mismatch as potentially outdated and tell the user to run `pc-run` to autoupdate. Repos not in the reference cannot be checked — skip them silently. Do not modify `rev` values yourself. - -The reference table's pins can themselves go stale between updates — treat a mismatch as a prompt to check, not a certainty. `pre-commit autoupdate` (via `pc-run`) is the authoritative source for what the current rev actually is. - -## Scope boundary - -This skill manages `.pre-commit-config.yaml` only. It does not: -- Author `.pre-commit-hooks.yaml` (publishing hooks for external consumers) -- Run `pre-commit install` -- Execute hooks or run the test suite -- Bump `rev` values - -For those operations, use `pc-run`. +1. State the proposed config or edit in full and wait for confirmation before writing. Hook choices are opinions imposed on everyone else's commit loop, not defaults to assume. +2. Run `pre-commit validate-config` after every write. On a non-zero exit, show the error and resolve it before reporting done — never leave a config that cannot be parsed. +3. Never edit a `rev` value. Report staleness and hand the bump to `pc-run`, which runs `autoupdate` against the hook repos themselves. diff --git a/plugins/git/skills/pc-author/references/README.md b/plugins/git/skills/pc-author/references/README.md index ba0a6c0..7bc855a 100644 --- a/plugins/git/skills/pc-author/references/README.md +++ b/plugins/git/skills/pc-author/references/README.md @@ -10,5 +10,7 @@ source_keys: | File | Purpose | |---|---| +| `create-config.md` | The create flow — read when the repo has no `.pre-commit-config.yaml` | +| `modify-config.md` | The modify flow — read when a `.pre-commit-config.yaml` already exists | | `hooks-by-language.md` | Hook recommendations by language/context — repo, rev, and rationale for adding hooks | | `sources.md` | Provenance: research sources that informed this skill | diff --git a/plugins/git/skills/pc-author/references/create-config.md b/plugins/git/skills/pc-author/references/create-config.md new file mode 100644 index 0000000..8adfdfb --- /dev/null +++ b/plugins/git/skills/pc-author/references/create-config.md @@ -0,0 +1,31 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Creating a `.pre-commit-config.yaml` + +Reached from `SKILL.md`'s Route table when the repo has no config yet. Self-contained — the modify +flow's file is not needed here. `SKILL.md`'s three common gates still apply. + +## Steps + +1. Detect what languages are actually in the repo with a shallow extension scan, rather than + inferring them from the project's name or README: + + ```bash + git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn + ``` + +2. Read `references/hooks-by-language.md` and map the detected extensions to recommended hooks. + Take the repo URL, `rev` and args from that file rather than from memory — a `rev` that does not + exist is the most common way a fresh config fails on its first run. + + For a deliberately minimal starting point instead of a full recommendation set, + `pre-commit sample-config > .pre-commit-config.yaml` prints a small starter config to build on. + +3. State the proposed config in full and wait for the user's confirmation. + +4. Write `.pre-commit-config.yaml`, then run `pre-commit validate-config`. If it exits non-zero, + show the error, fix it in place, and re-validate. diff --git a/plugins/git/skills/pc-author/references/hooks-by-language.md b/plugins/git/skills/pc-author/references/hooks-by-language.md index 8d5a382..ab8aabe 100644 --- a/plugins/git/skills/pc-author/references/hooks-by-language.md +++ b/plugins/git/skills/pc-author/references/hooks-by-language.md @@ -11,6 +11,11 @@ source_keys: Use this table when creating a config from scratch or recommending hooks to add. Always check the existing config for duplicates before proposing. +Fixer hooks (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json` and the like) rewrite +files but do NOT re-stage them, so the commit is still blocked and the user has to stage and commit +again. Say so when proposing one — otherwise the first blocked commit reads as the hook being +broken. + ## Universal (recommend for every repo) | Hook ID | Repo | Rev | Rationale | @@ -101,6 +106,9 @@ Use for repo-specific scripts that don't belong in an external hook repo. stages: [pre-push] ``` +`language: system` and `language: script` are deprecated names for the first two below. +New local hooks use `unsupported` and `unsupported_script`. + Language choices for local hooks: - `unsupported` — system PATH tool (pre-commit does not manage env) - `unsupported_script` — script at a repo-relative path @@ -117,4 +125,4 @@ Language choices for local hooks: ## Rev pin freshness -The revs above were last verified current at time of writing (matched against the plugin's own research corpus in `docs/research/docs/pre-commit/`; rows marked "Unverified" have no such backing and must be checked against upstream before use). Since `pc-author`'s "Rev staleness" check treats this table as ground truth, a pin that goes stale here produces false-positive staleness warnings for users who already have a newer, correct rev. Re-verify these pins periodically (e.g. against each repo's latest release tag). When in doubt, treat `pre-commit autoupdate`'s own output as the authoritative staleness signal, not a mismatch against this table. +The revs above were last verified current at time of writing (matched against the plugin's own research corpus in `docs/research/docs/pre-commit/`; rows marked "Unverified" have no such backing and must be checked against upstream before use). Since the "Rev staleness" check in `references/modify-config.md` treats this table as ground truth, a pin that goes stale here produces false-positive staleness warnings for users who already have a newer, correct rev. Re-verify these pins periodically (e.g. against each repo's latest release tag). When in doubt, treat `pre-commit autoupdate`'s own output as the authoritative staleness signal, not a mismatch against this table. diff --git a/plugins/git/skills/pc-author/references/modify-config.md b/plugins/git/skills/pc-author/references/modify-config.md new file mode 100644 index 0000000..83a0ae6 --- /dev/null +++ b/plugins/git/skills/pc-author/references/modify-config.md @@ -0,0 +1,75 @@ +--- +source_keys: + - context7-pre-commit-com + - pre-commit-com +--- + +# Modifying an existing `.pre-commit-config.yaml` + +Reached from `SKILL.md`'s Route table when the repo already has a config. Self-contained — the +create flow's file is not needed here. `SKILL.md`'s three common gates still apply. + +Read the existing `.pre-commit-config.yaml` before editing. Note any stale `rev` values (see +**Rev staleness** below) but do not change them. + +## Adding a hook + +1. Run a shallow extension scan, so the addition is judged against the languages actually present: + + ```bash + git ls-files | grep -oE '\.[a-z]+$' | sort | uniq -c | sort -rn + ``` + +2. Read `references/hooks-by-language.md` for the correct repo URL, `rev` and recommended args + before writing anything. + +3. Check for duplicates. If the same hook ID, or an equivalent tool, is already configured, say so + and stop rather than adding a second one. + +4. To sanity-check a hook against the repo's real files before committing to it, smoke-test it: + + ```bash + pre-commit try-repo <repo-url> <hook-id> --verbose + ``` + + Use a local path in place of the URL for a hook under development. This runs the hook without + writing anything. + +5. If the hook's source repo is already a block in the config, add the hook under that block. + Otherwise append a new repo block. + +6. State the proposed addition, wait for confirmation, write, and run `pre-commit validate-config`. + On a non-zero exit, show the error, fix it, and re-validate. + +## Removing a hook + +1. Identify the hook entry and its parent repo block. + +2. State what will be removed — the hook ID, and whether the parent repo block goes with it because + it would be left with zero hooks. Wait for confirmation. + +3. Remove the hook entry. If the repo block now has no hooks left, remove the whole block: an empty + `hooks: []` fails `validate-config`. + +4. Write, then run `pre-commit validate-config`. On a non-zero exit, **revert the edit**, show the + error, and stop. A removal is not safely fixable in place the way a malformed new hook block is, + so recovering the prior state beats patching forward. + +## Configuring top-level keys + +Only when the user explicitly asks. Valid keys: `fail_fast`, `default_stages`, +`default_language_version`, `minimum_pre_commit_version`, `exclude`, `files`, +`default_install_hook_types`. + +State the proposed change and wait for confirmation before writing. + +## Rev staleness + +For each repo in the config that also appears in `references/hooks-by-language.md`, compare the +two `rev` values. Flag a mismatch as potentially outdated and tell the user to run `pc-run` to +autoupdate. Repos absent from the reference cannot be checked — skip them silently. Do not modify +any `rev` yourself. + +The reference table's own pins go stale between updates, so treat a mismatch as a prompt to check +rather than proof of staleness. `pre-commit autoupdate`, via `pc-run`, is the authoritative answer +to what the current `rev` actually is. diff --git a/plugins/git/skills/pc-author/references/sources.md b/plugins/git/skills/pc-author/references/sources.md index 651e610..24f21a3 100644 --- a/plugins/git/skills/pc-author/references/sources.md +++ b/plugins/git/skills/pc-author/references/sources.md @@ -4,7 +4,7 @@ - **URL:** context7:/pre-commit/pre-commit.com - **Description:** Official pre-commit.com documentation — installation, configuration schema, CLI reference, hook authoring, advanced features, troubleshooting -- **Contributing files:** SKILL.md, references/hooks-by-language.md +- **Contributing files:** SKILL.md, references/create-config.md, references/modify-config.md, references/hooks-by-language.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,configuration,cli-reference,hook-authoring}.md - **Status:** `extracted` @@ -12,7 +12,7 @@ - **URL:** https://pre-commit.com/ - **Description:** Pre-commit framework homepage — full docs covering install, config, CLI, hook authoring, stages, local hooks, meta hooks, hazmat helpers, CI integration -- **Contributing files:** SKILL.md, references/hooks-by-language.md +- **Contributing files:** SKILL.md, references/create-config.md, references/modify-config.md, references/hooks-by-language.md - **Research doc:** plugins/git/docs/research/docs/pre-commit/{overview,configuration,cli-reference,hook-authoring}.md - **Status:** `extracted` -- 2.43.0 From afbeaff56b2101ffdd5b4ecd9e3ae681e05853b4 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:14:00 +0000 Subject: [PATCH 23/89] docs(agents): correct the ADR-0020 gate counts after the git retrofit Descriptions over the FAIL tier 19 -> 10, bodies 7 -> 2. This line is always-loaded context, so a stale count misleads every session. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ba1049d..5e2d009 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline.** 19 of 39 descriptions and 7 of 39 bodies exceed their FAIL tier, and one routing target still dangles (`research` → `neuledge-context`). Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin. The `Kyberforge.CompositionNote` Vale rule currently fires nowhere, but `skill-size-check` does not cover the Vale half and the rule can be reintroduced by any new description, so check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline.** 10 of 39 descriptions and 2 of 39 bodies exceed their FAIL tier, and one routing target still dangles (`research` → `neuledge-context`). Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin. The `Kyberforge.CompositionNote` Vale rule currently fires nowhere, but `skill-size-check` does not cover the Vale half and the rule can be reintroduced by any new description, so check both: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. -- 2.43.0 From a2ebdafc5ea985848d973ba7a71f85ffb688468b Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 13:22:55 +0000 Subject: [PATCH 24/89] fix(skill-audit): pin the stale worked example and state its reachability precondition The body-discipline rubric cited git-commits as it stood before the ADR-0020 retrofit -- twelve Gotchas, 387/1102 words, line numbers :31-:52. Every figure was correct for that version and none survives in the current file, so the example is now anchored to commit 5e23250 and marked not to be refreshed against HEAD. More than staleness: row four called the secrets Gotcha a paraphrase FAIL because step 2 restated it. Wave 2 followed that reasoning, deleted the always-loaded copy, and left the amend branch able to commit a credential unchecked -- dispatch loads exactly one flow file. The paraphrase rule now carries its missing precondition: delete a restating Gotcha only when the surviving copy is reachable from every branch that needs it, and relocate a multi-branch safety gate into the body rather than dropping it. --- .../skill-audit/references/body-discipline.md | 36 ++++++++++++++++--- .../skill-audit/references/body-discipline.md | 36 ++++++++++++++++--- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index 4f4af51..45430d2 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -96,19 +96,25 @@ 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`. Read the snapshot with +`git show 5e23250:plugins/git/.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 +124,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 @@ -153,7 +178,8 @@ 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 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 diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index 4f4af51..45430d2 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -96,19 +96,25 @@ 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`. Read the snapshot with +`git show 5e23250:plugins/git/.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 +124,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 @@ -153,7 +178,8 @@ 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 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 -- 2.43.0 From ee6b04061a9a76fcf8c22fe6a12ab109beb276cb Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:23 +0000 Subject: [PATCH 25/89] refactor(bin): retrofit research to the ADR-0020 context contract Description 583 -> 231 chars and body 854 -> 519 words. Deletes the neuledge-context boundary clause outright: commit 6146120 deleted that skill and no skill has owned MCP-server installation since. That was the last dangling routing target in the corpus. Removes META.md, which file-structure.md:20 forbids at a skill root. Its when: field duplicated the description and its references: entry pointed at .agents/skills/context7-mcp/SKILL.md, which does not exist. Restores two rules a clean-context audit found had lost their force: the starting-URLs branch in step 3, which the retrofit had reduced to a condition with no behaviour, and the references/file-format.md pointer at step 6. The second matters downstream -- validate-provenance.sh parses sources.md with anchored regexes and check 8 short-circuits silently when the Status field is absent, so a sources.md written from step 6 alone broke the provenance chain with no error anywhere. Rewrites steps 4-5 as serial WebFetch reads. They mandated spawning subagents that allowed-tools never granted; no tool was added because the name differs across the three compile targets. Tracked as #116. Updates the two test pins and the eval case that asserted the dead route. Refs #99 --- plugins/bin/.apm/skills/research/META.md | 15 --- plugins/bin/.apm/skills/research/SKILL.md | 91 +++++---------- plugins/bin/evals/research/research/eval.yaml | 5 - plugins/bin/skills/research/META.md | 15 --- plugins/bin/skills/research/SKILL.md | 91 +++++---------- tests/test-adr0020-targets.sh | 104 ++++++++++-------- tests/test-skill-size-check.sh | 51 +++++---- 7 files changed, 139 insertions(+), 233 deletions(-) delete mode 100644 plugins/bin/.apm/skills/research/META.md delete mode 100644 plugins/bin/skills/research/META.md diff --git a/plugins/bin/.apm/skills/research/META.md b/plugins/bin/.apm/skills/research/META.md deleted file mode 100644 index 824f4c3..0000000 --- a/plugins/bin/.apm/skills/research/META.md +++ /dev/null @@ -1,15 +0,0 @@ -```yaml -version: "1.1" -updated: 2026-06-21 - -when: >- - Invoked when the user wants to gather structured reference documentation for a - tool, library, or API from MCP documentation indexes or web sources. Typically - run before writing a new skill that wraps an external tool, or any time - reference files are needed for a topic. Triggered explicitly - ("/research <topic> <path>") or implicitly when the user asks to look up, - gather, or pull docs for a topic before implementing something. - -references: - - .agents/skills/context7-mcp/SKILL.md # context7-mcp — MCP source channel integrated at step 2 -``` diff --git a/plugins/bin/.apm/skills/research/SKILL.md b/plugins/bin/.apm/skills/research/SKILL.md index 1453ddb..2ab6459 100644 --- a/plugins/bin/.apm/skills/research/SKILL.md +++ b/plugins/bin/.apm/skills/research/SKILL.md @@ -1,14 +1,10 @@ --- name: research description: >- - Use when the user wants to research a topic and generate structured reference - markdown files. Handles: finding canonical docs for a tool/library/API via - Context7 MCP or web sources, reading and deepening into linked pages, - organizing extracted content into topic files (overview, installation, - configuration, cli-reference, api-reference, examples, troubleshooting). Do - NOT use when the user wants to write documentation from existing code or specs - (use write-docs), install or manage the neuledge-context MCP server (use - neuledge-context), or research a bug/incident (use diagnose). + Use when the user wants a tool, library, or API researched from canonical + documentation into structured per-topic reference markdown files. Not + documentation written from existing code or specs -> `write-docs`. Not a bug + or incident -> `diagnose`. metadata: category: research allowed-tools: @@ -21,77 +17,48 @@ allowed-tools: model: sonnet --- -<requirements> +## Gotchas -## Required inputs +- Never infer the output path. A run writes a directory's worth of files, and a guessed destination scatters them through someone's source tree. If the user named no path, stop and ask. +- Write nothing outside the given output path. A file placed beside the agreed directory is one the user never asked for and will not think to look for. +- Never write an empty topic file. A stub `troubleshooting.md` reads downstream as researched and closed. +- A Context7 response that is a "no results" message, a redirect notice, or header-only boilerplate is not coverage. A topic area counts as covered only when the response carries at least one substantive paragraph. -- **Topic** — the subject to research (tool, library, API, concept); inferred from user description if clear, ask if ambiguous -- **Output path** — directory where reference files will be written; must be provided explicitly — do not infer or default -- **Starting URLs** — optional; if provided, skip discovery websearch and read these first +## Step 1 — Scope against the working directory -## Constraints +Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -- Never write files outside the explicitly provided output path -- Skip any default topic file if no relevant content is found for it — do not create empty files -- Create additional topic files beyond the default list when content warrants it (e.g. `webhooks.md`, `rate-limits.md`) -- Subagents handle parallel source reading and link deepening — the orchestrator writes all files; subagents return summaries only, never write directly -- Context7 MCP calls (`resolve-library-id`, `query-docs`) are made only by the orchestrator at step 2 — subagents must not call them -- `sources.md` is always written, even if only one source was read -- Each topic file must have frontmatter with `topic` and `source_keys`; body is prose only — no inline URLs -- Source keys in `sources.md` must be kebab-case slugs: derived from the source domain or page title for web sources; for Context7 sources use `context7-<library-slug>` (e.g. `context7-vercel-next-js`) -- Default topic list and file format spec live in `references/` sub-files — read them at step 1 +Read `references/topics.md` before narrowing, for the default topic list. -</requirements> +## Step 2 — Resolve against Context7 -<steps> +If the topic is a library, framework, or API and the user gave no starting URLs, call `resolve-library-id` with the topic name and the user's full question — match quality depends on the question, not the bare name — then `query-docs` once per default topic area. Record each response as a source with slug `context7-<library-slug>`, and mark which topic areas it covered — those skip the web reads at step 4. -## Process +If the library does not resolve, or the user gave starting URLs, go to step 3. Explicit URLs are a source choice; do not second-guess them with a resolution attempt. -1. **Scan codebase.** Search the working directory for existing usage of the topic — imports, config files, version pins, existing reference files. Use findings to narrow research scope (e.g. target the version already in use, skip topics already documented). Read `references/topics.md` for the default topic list and `references/file-format.md` for the output file format spec. +## Step 3 — Discover sources -2. **Try Context7.** If the topic is a library, framework, or API and no starting URLs were provided, call `resolve-library-id` with the topic name and the user's question. If a match resolves, call `query-docs` once per default topic area (see `references/topics.md`). Treat each response as a source summary with slug `context7-<library-slug>` (e.g. `context7-vercel-next-js`). A topic area has sufficient content when the Context7 response contains at least one substantive paragraph — not a "no results" message, redirect notice, or header-only boilerplate. Mark covered topic areas — skip their subagent web reads in step 4. If the library does not resolve, or starting URLs were provided (explicit source choice by the user), skip this step entirely. +If the user gave starting URLs, skip discovery: those URLs are the source list and go straight to step 4. -3. **Discover sources.** For topics not covered by Context7 (or when no starting URLs were provided and Context7 did not resolve), websearch for canonical documentation (prefer `llms.txt`, developer docs, official API references over tutorials or blog posts). Collect 3–5 candidate URLs before reading any. +Otherwise, for every topic area Context7 did not cover, websearch for canonical documentation — `llms.txt`, official developer docs, and API references ahead of tutorials or blog posts. Collect three to five candidate URLs before reading any of them. -4. **Read sources in parallel.** Spawn one subagent per source URL. Each subagent fetches the page, extracts relevant content, identifies links worth deepening, and returns a structured summary (content by topic area + links to follow). Subagents do not write files. +If nothing usable comes back, stop and report what was searched, then ask for starting URLs rather than settling for tutorials. -5. **Deepen.** For each subagent that returned links worth following, spawn child subagents per branch. Continue until content becomes repetitive or out of scope. Cap at ~10 additional pages total across all branches. +## Step 4 — Read the sources -6. **Consolidate.** Merge all subagent summaries (Context7 and web) by topic area. Identify which default topics have sufficient content and which custom topics emerged. +`WebFetch` each URL in turn. No subagent tool is granted here, so the reads are serial and every fetched page lands in this context: reduce each page to notes by topic area, plus the links worth deepening, before fetching the next one. -7. **Write topic files.** For each topic with content, write `<output-path>/<topic>.md` using the format in `references/file-format.md`. Orchestrator writes all files — never delegate file writing to a subagent. +## Step 5 — Deepen -8. **Write `sources.md`.** Write `<output-path>/sources.md` mapping each source slug to its URL (use `context7:<library-id>` as the URL for Context7 sources), description, and list of topic files it contributed to. Include sources that yielded no content, marked `no content extracted`. +`WebFetch` the links worth following, still one at a time and still reducing each page to notes. Stop a branch once its content turns repetitive or leaves the topic, and cap the whole step at roughly ten additional pages — serial reads make that cap a real budget, not a formality. -## Output format +## Step 6 — Write -- `<output-path>/<topic>.md` per topic with content — formatted per `references/file-format.md` -- `<output-path>/sources.md` — always produced; maps slug → URL, description, contributing files +Merge every set of notes, Context7 and web alike, by topic area. Read `references/file-format.md`, then write, in the output path: -</steps> +- `<topic>.md` for each topic area that has content, default or custom +- `sources.md`, always, one section per source in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` -<checks> +Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -## Failure handling - -- Output path not provided — stop and ask; do not infer or default -- No sources found after websearch — report what was searched, ask user to provide starting URLs -- Subagent returns no usable content — skip that source, log in `sources.md` as `no content extracted` -- All topic files would be empty — stop, report what was searched, do not write any files - -## Self-check - -- [ ] Codebase scanned before any websearch was performed -- [ ] Output path was explicitly provided — not inferred -- [ ] `references/topics.md` and `references/file-format.md` read at step 1 -- [ ] Context7 resolution attempted before websearch when topic is a library/framework/API -- [ ] Context7 calls made only at orchestrator step 2 — no subagent called `resolve-library-id` or `query-docs` -- [ ] Context7 sources recorded in `sources.md` with `context7:<library-id>` as URL -- [ ] No topic file written without content -- [ ] `sources.md` written with all sources read (including those with no content extracted) -- [ ] All file writes performed by the orchestrator, not subagents -- [ ] Each topic file has `topic` and `source_keys` frontmatter fields -- [ ] All source keys in topic files have a matching entry in `sources.md` -- [ ] No files written outside the provided output path - -</checks> +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. A directory of empty files is worse than an honest miss. diff --git a/plugins/bin/evals/research/research/eval.yaml b/plugins/bin/evals/research/research/eval.yaml index 1f402f1..39ad9c6 100644 --- a/plugins/bin/evals/research/research/eval.yaml +++ b/plugins/bin/evals/research/research/eval.yaml @@ -26,11 +26,6 @@ trigger_tests: query: "Research why these integration tests are failing" should_trigger: false - - id: negative-neuledge - name: "Negative — MCP server setup goes to neuledge-context" - query: "Install the neuledge context server and set it up" - should_trigger: false - - id: negative-context7-direct-question name: "Negative — direct doc question goes to context7-mcp, not research" query: "What are the Next.js middleware options?" diff --git a/plugins/bin/skills/research/META.md b/plugins/bin/skills/research/META.md deleted file mode 100644 index 824f4c3..0000000 --- a/plugins/bin/skills/research/META.md +++ /dev/null @@ -1,15 +0,0 @@ -```yaml -version: "1.1" -updated: 2026-06-21 - -when: >- - Invoked when the user wants to gather structured reference documentation for a - tool, library, or API from MCP documentation indexes or web sources. Typically - run before writing a new skill that wraps an external tool, or any time - reference files are needed for a topic. Triggered explicitly - ("/research <topic> <path>") or implicitly when the user asks to look up, - gather, or pull docs for a topic before implementing something. - -references: - - .agents/skills/context7-mcp/SKILL.md # context7-mcp — MCP source channel integrated at step 2 -``` diff --git a/plugins/bin/skills/research/SKILL.md b/plugins/bin/skills/research/SKILL.md index 1453ddb..2ab6459 100644 --- a/plugins/bin/skills/research/SKILL.md +++ b/plugins/bin/skills/research/SKILL.md @@ -1,14 +1,10 @@ --- name: research description: >- - Use when the user wants to research a topic and generate structured reference - markdown files. Handles: finding canonical docs for a tool/library/API via - Context7 MCP or web sources, reading and deepening into linked pages, - organizing extracted content into topic files (overview, installation, - configuration, cli-reference, api-reference, examples, troubleshooting). Do - NOT use when the user wants to write documentation from existing code or specs - (use write-docs), install or manage the neuledge-context MCP server (use - neuledge-context), or research a bug/incident (use diagnose). + Use when the user wants a tool, library, or API researched from canonical + documentation into structured per-topic reference markdown files. Not + documentation written from existing code or specs -> `write-docs`. Not a bug + or incident -> `diagnose`. metadata: category: research allowed-tools: @@ -21,77 +17,48 @@ allowed-tools: model: sonnet --- -<requirements> +## Gotchas -## Required inputs +- Never infer the output path. A run writes a directory's worth of files, and a guessed destination scatters them through someone's source tree. If the user named no path, stop and ask. +- Write nothing outside the given output path. A file placed beside the agreed directory is one the user never asked for and will not think to look for. +- Never write an empty topic file. A stub `troubleshooting.md` reads downstream as researched and closed. +- A Context7 response that is a "no results" message, a redirect notice, or header-only boilerplate is not coverage. A topic area counts as covered only when the response carries at least one substantive paragraph. -- **Topic** — the subject to research (tool, library, API, concept); inferred from user description if clear, ask if ambiguous -- **Output path** — directory where reference files will be written; must be provided explicitly — do not infer or default -- **Starting URLs** — optional; if provided, skip discovery websearch and read these first +## Step 1 — Scope against the working directory -## Constraints +Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -- Never write files outside the explicitly provided output path -- Skip any default topic file if no relevant content is found for it — do not create empty files -- Create additional topic files beyond the default list when content warrants it (e.g. `webhooks.md`, `rate-limits.md`) -- Subagents handle parallel source reading and link deepening — the orchestrator writes all files; subagents return summaries only, never write directly -- Context7 MCP calls (`resolve-library-id`, `query-docs`) are made only by the orchestrator at step 2 — subagents must not call them -- `sources.md` is always written, even if only one source was read -- Each topic file must have frontmatter with `topic` and `source_keys`; body is prose only — no inline URLs -- Source keys in `sources.md` must be kebab-case slugs: derived from the source domain or page title for web sources; for Context7 sources use `context7-<library-slug>` (e.g. `context7-vercel-next-js`) -- Default topic list and file format spec live in `references/` sub-files — read them at step 1 +Read `references/topics.md` before narrowing, for the default topic list. -</requirements> +## Step 2 — Resolve against Context7 -<steps> +If the topic is a library, framework, or API and the user gave no starting URLs, call `resolve-library-id` with the topic name and the user's full question — match quality depends on the question, not the bare name — then `query-docs` once per default topic area. Record each response as a source with slug `context7-<library-slug>`, and mark which topic areas it covered — those skip the web reads at step 4. -## Process +If the library does not resolve, or the user gave starting URLs, go to step 3. Explicit URLs are a source choice; do not second-guess them with a resolution attempt. -1. **Scan codebase.** Search the working directory for existing usage of the topic — imports, config files, version pins, existing reference files. Use findings to narrow research scope (e.g. target the version already in use, skip topics already documented). Read `references/topics.md` for the default topic list and `references/file-format.md` for the output file format spec. +## Step 3 — Discover sources -2. **Try Context7.** If the topic is a library, framework, or API and no starting URLs were provided, call `resolve-library-id` with the topic name and the user's question. If a match resolves, call `query-docs` once per default topic area (see `references/topics.md`). Treat each response as a source summary with slug `context7-<library-slug>` (e.g. `context7-vercel-next-js`). A topic area has sufficient content when the Context7 response contains at least one substantive paragraph — not a "no results" message, redirect notice, or header-only boilerplate. Mark covered topic areas — skip their subagent web reads in step 4. If the library does not resolve, or starting URLs were provided (explicit source choice by the user), skip this step entirely. +If the user gave starting URLs, skip discovery: those URLs are the source list and go straight to step 4. -3. **Discover sources.** For topics not covered by Context7 (or when no starting URLs were provided and Context7 did not resolve), websearch for canonical documentation (prefer `llms.txt`, developer docs, official API references over tutorials or blog posts). Collect 3–5 candidate URLs before reading any. +Otherwise, for every topic area Context7 did not cover, websearch for canonical documentation — `llms.txt`, official developer docs, and API references ahead of tutorials or blog posts. Collect three to five candidate URLs before reading any of them. -4. **Read sources in parallel.** Spawn one subagent per source URL. Each subagent fetches the page, extracts relevant content, identifies links worth deepening, and returns a structured summary (content by topic area + links to follow). Subagents do not write files. +If nothing usable comes back, stop and report what was searched, then ask for starting URLs rather than settling for tutorials. -5. **Deepen.** For each subagent that returned links worth following, spawn child subagents per branch. Continue until content becomes repetitive or out of scope. Cap at ~10 additional pages total across all branches. +## Step 4 — Read the sources -6. **Consolidate.** Merge all subagent summaries (Context7 and web) by topic area. Identify which default topics have sufficient content and which custom topics emerged. +`WebFetch` each URL in turn. No subagent tool is granted here, so the reads are serial and every fetched page lands in this context: reduce each page to notes by topic area, plus the links worth deepening, before fetching the next one. -7. **Write topic files.** For each topic with content, write `<output-path>/<topic>.md` using the format in `references/file-format.md`. Orchestrator writes all files — never delegate file writing to a subagent. +## Step 5 — Deepen -8. **Write `sources.md`.** Write `<output-path>/sources.md` mapping each source slug to its URL (use `context7:<library-id>` as the URL for Context7 sources), description, and list of topic files it contributed to. Include sources that yielded no content, marked `no content extracted`. +`WebFetch` the links worth following, still one at a time and still reducing each page to notes. Stop a branch once its content turns repetitive or leaves the topic, and cap the whole step at roughly ten additional pages — serial reads make that cap a real budget, not a formality. -## Output format +## Step 6 — Write -- `<output-path>/<topic>.md` per topic with content — formatted per `references/file-format.md` -- `<output-path>/sources.md` — always produced; maps slug → URL, description, contributing files +Merge every set of notes, Context7 and web alike, by topic area. Read `references/file-format.md`, then write, in the output path: -</steps> +- `<topic>.md` for each topic area that has content, default or custom +- `sources.md`, always, one section per source in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` -<checks> +Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -## Failure handling - -- Output path not provided — stop and ask; do not infer or default -- No sources found after websearch — report what was searched, ask user to provide starting URLs -- Subagent returns no usable content — skip that source, log in `sources.md` as `no content extracted` -- All topic files would be empty — stop, report what was searched, do not write any files - -## Self-check - -- [ ] Codebase scanned before any websearch was performed -- [ ] Output path was explicitly provided — not inferred -- [ ] `references/topics.md` and `references/file-format.md` read at step 1 -- [ ] Context7 resolution attempted before websearch when topic is a library/framework/API -- [ ] Context7 calls made only at orchestrator step 2 — no subagent called `resolve-library-id` or `query-docs` -- [ ] Context7 sources recorded in `sources.md` with `context7:<library-id>` as URL -- [ ] No topic file written without content -- [ ] `sources.md` written with all sources read (including those with no content extracted) -- [ ] All file writes performed by the orchestrator, not subagents -- [ ] Each topic file has `topic` and `source_keys` frontmatter fields -- [ ] All source keys in topic files have a matching entry in `sources.md` -- [ ] No files written outside the provided output path - -</checks> +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. A directory of empty files is worse than an honest miss. diff --git a/tests/test-adr0020-targets.sh b/tests/test-adr0020-targets.sh index f4bf7af..8fd7d69 100755 --- a/tests/test-adr0020-targets.sh +++ b/tests/test-adr0020-targets.sh @@ -362,35 +362,47 @@ cp -R "$REPO_ROOT/plugins" "$FRESH_ROOT/plugins" [[ -f "$REPO_ROOT/apm.yml" ]] && cp "$REPO_ROOT/apm.yml" "$FRESH_ROOT/apm.yml" FRESH_DANGLING="$(dangling_set "$FRESH_ROOT/plugins")" -DEPLOYED_ROOT="$TMPDIR_T/deployed-clone" -mkdir -p "$DEPLOYED_ROOT/.claude/skills" "$DEPLOYED_ROOT/.claude/agents" -cp -R "$REPO_ROOT/plugins" "$DEPLOYED_ROOT/plugins" -[[ -f "$REPO_ROOT/apm.yml" ]] && cp "$REPO_ROOT/apm.yml" "$DEPLOYED_ROOT/apm.yml" -# Deploy exactly the names that currently dangle. That is the strongest possible -# bait: if the deployed tree were consulted, every one of them would resolve and -# the dangling set would collapse to empty. -DEPLOY_COUNT=0 -while IFS= read -r name; do - [[ -n "$name" ]] || continue - mkdir -p "$DEPLOYED_ROOT/.claude/skills/$name" - DEPLOY_COUNT=$((DEPLOY_COUNT + 1)) -done <<< "$FRESH_DANGLING" -DEPLOYED_DANGLING="$(dangling_set "$DEPLOYED_ROOT/plugins")" +# The bait used to be DERIVED from the corpus: deploy exactly the names that +# currently dangle. That was the strongest bait available while the corpus had +# dangling names — and it silently became vacuous the moment issue #99 fixed the +# last one, because a corpus reporting nothing gives nothing to deploy. A test of +# "deployed trees do not leak" must not depend on the corpus staying broken. +# +# So the bait is now EXPLICIT. Both contrast copies get one synthetic skill whose +# boundary clause routes to a name guaranteed absent from the monorepo, and only +# the deployed copy gets that name planted in .claude/skills/. If deployed trees +# leaked into the resolver's universe, the deployed copy would resolve it and +# report an empty set while the fresh copy reported one. The A/B now distinguishes +# something on every run, forever, whatever the corpus does. +BAIT_NAME="no-such-deployed-only-skill" +BAIT_FRESH="$TMPDIR_T/bait-fresh" +BAIT_DEPLOYED="$TMPDIR_T/bait-deployed" +for bait_root in "$BAIT_FRESH" "$BAIT_DEPLOYED"; do + mkdir -p "$bait_root" + cp -R "$REPO_ROOT/plugins" "$bait_root/plugins" + [[ -f "$REPO_ROOT/apm.yml" ]] && cp "$REPO_ROOT/apm.yml" "$bait_root/apm.yml" + write_skill "$bait_root/plugins/bin/.apm/skills/deployed-tree-probe" deployed-tree-probe \ + "Use when doing the probe thing. Do not use for the other thing — use $BAIT_NAME instead." +done +# Only the deployed copy gets the name planted where `apm install` would put it. +mkdir -p "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_DEPLOYED/.claude/agents" +BAIT_FRESH_DANGLING="$(dangling_set "$BAIT_FRESH/plugins")" +BAIT_DEPLOYED_DANGLING="$(dangling_set "$BAIT_DEPLOYED/plugins")" -if [[ "$DEPLOY_COUNT" -gt 0 ]]; then - pass "precondition: $DEPLOY_COUNT dangling name(s) deployed into the contrast tree's .claude/skills/, so the A/B has something to distinguish" +if [[ "$BAIT_FRESH_DANGLING" == *"$BAIT_NAME"* ]]; then + pass "precondition: the bait target dangles in the un-deployed copy, so the A/B has something to distinguish" else - fail "no dangling names to deploy — the corpus reports none, so this A/B distinguishes nothing. Deploy a known-absent name explicitly instead of deriving one." + fail "the bait target '$BAIT_NAME' does not dangle even without a deployed tree — the fixture is broken, so the contrast below proves nothing. Got: [$(echo "$BAIT_FRESH_DANGLING" | tr '\n' ' ')]" fi -if [[ ! -d "$FRESH_ROOT/.claude" && ! -d "$FRESH_ROOT/.agents" ]]; then +if [[ ! -d "$BAIT_FRESH/.claude" && ! -d "$BAIT_FRESH/.agents" ]]; then pass "precondition: the fresh-clone copy has no deployed tree of its own" else fail "the fresh-clone copy picked up a deployed tree — it is not a fresh-clone fixture" fi -if [[ "$FRESH_DANGLING" == "$DEPLOYED_DANGLING" ]]; then - pass "deploying every dangling name into .claude/skills/ changes nothing: $(echo "$FRESH_DANGLING" | tr '\n' ' ')" +if [[ "$BAIT_FRESH_DANGLING" == "$BAIT_DEPLOYED_DANGLING" ]]; then + pass "planting the dangling name in .claude/skills/ changes nothing: $(echo "$BAIT_DEPLOYED_DANGLING" | tr '\n' ' ')" else - fail "the corpus verdict depends on whether apm install has been run — fresh clone: [$(echo "$FRESH_DANGLING" | tr '\n' ' ')] with a deployed tree: [$(echo "$DEPLOYED_DANGLING" | tr '\n' ' ')]" + fail "the verdict depends on whether apm install has been run — fresh clone: [$(echo "$BAIT_FRESH_DANGLING" | tr '\n' ' ')] with a deployed tree: [$(echo "$BAIT_DEPLOYED_DANGLING" | tr '\n' ' ')]. A deployed .claude/skills/ tree is leaking into the resolver's universe." fi # Third data point: whatever state THIS machine happens to be in, the live tree # must agree with a bare copy of the same plugins/. No precondition on that state @@ -417,37 +429,33 @@ fi # terminal and therefore danglable. The issue #99 retrofit cut that composition # sentence and the dangling target went with it, so the set is down to one. # -# WHEN `research` IS RETROFITTED: drop neuledge-context and leave the set empty. -# Do not delete the assertion — an empty expected set is fine and still pins -# that no NEW dangling target appeared. +# `neuledge-context` was the last one. The issue #99 wave-3 retrofit deleted that +# boundary clause outright — commit `6146120` had already deleted the skill it +# named, and nothing has owned MCP-server installation since — so the corpus +# dangling set is now EMPTY. +# +# The assertion stays, and it is not vacuous now that it expects nothing: it is +# the only thing standing between a newly-authored boundary clause naming a +# non-existent target and a green suite. An empty expected set pins "no NEW +# dangling target appeared", which is the property that actually matters from +# here on. +# +# The per-target probe loop that used to sit below is GONE, not emptied. Its job +# was to prove the check detects each live dangling target individually, and with +# no live targets left there is nothing to point it at. A loop over an empty list +# is an assertion-free result counted in the totals — exactly the vacuous-pass +# shape the comment above rejects. Detection is still covered, and covered +# better, by the synthetic fixtures in section 2 below, which build a real plugin +# tree and assert the resolver fires. Do not reinstate the loop unless a real +# dangling target reappears in the corpus. echo "" -echo "--- the live dangling targets in the corpus are exactly the ADR-0020 records still open ---" -EXPECTED_DANGLING="$(printf '%s\n' neuledge-context)" +echo "--- no skill in the corpus routes to a target that does not resolve ---" +EXPECTED_DANGLING="" if [[ "$LIVE_DANGLING" == "$EXPECTED_DANGLING" ]]; then - pass "the corpus dangling set is exactly {neuledge-context}" + pass "the corpus dangling set is empty" else - fail "the corpus dangling set changed — expected [$(echo "$EXPECTED_DANGLING" | tr '\n' ' ')], got [$(echo "$LIVE_DANGLING" | tr '\n' ' ')]. If a retrofit fixed one, update EXPECTED_DANGLING; if a false-positive fix silently deleted one, that is the regression this asserts." + fail "a dangling routing target appeared in the corpus — expected none, got [$(echo "$LIVE_DANGLING" | tr '\n' ' ')]. A boundary clause names a skill or agent that does not resolve; fix the clause or the target. This assertion is the corpus-wide backstop, so do not relax it to make a new skill pass." fi -# shellcheck disable=SC2043 # one probe left by design -- the list shrinks as -# each fixture is retrofitted and reaches zero when `research` lands. Keeping the -# loop means removing the last entry is a one-line edit, not a restructure. -for probe in \ - "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context"; do - probe_file="$REPO_ROOT/${probe%%:*}" - probe_name="${probe##*:}" - if [[ ! -f "$probe_file" ]]; then - fail "the true-positive fixture ${probe%%:*} no longer exists — this pin has become vacuous" - continue - fi - set +e - probe_out="$(bash "$HOOK" "$probe_file" 2>&1)" - set -e - if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then - pass "detects the dangling '$probe_name' target in ${probe%%:*}" - else - fail "did not detect the dangling '$probe_name' target in ${probe%%:*} — a false-positive fix has taken a true positive with it: $probe_out" - fi -done # --------------------------------------------------------------------------- # 2. The bare-target grammar rule diff --git a/tests/test-skill-size-check.sh b/tests/test-skill-size-check.sh index 6356551..3423850 100755 --- a/tests/test-skill-size-check.sh +++ b/tests/test-skill-size-check.sh @@ -534,10 +534,12 @@ expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \ "Use when doing the thing. Do not use for improvements — use some-other-skill instead." 10)" \ "Unchecked target(s): some-other-skill" -echo "" -echo "--- the live dangling routing targets are caught (issue #100) ---" +# NOTE: this section prints no header and runs no assertions any more — see why +# below. The commentary is kept because it records why probes are removed rather +# than skipped, which is the rule the next person to touch this file needs. +# # ADR-0020 records the broken routing targets and splits fixing them into its own -# issue. This asserts the gate actually sees them rather than the check being +# issue. This asserted the gate actually sees them rather than the check being # vacuous in the corpus it was written against. # # There used to be a third probe here, for `skill-improve` in skill-audit's @@ -556,29 +558,26 @@ echo "--- the live dangling routing targets are caught (issue #100) ---" # The gitea-labels probe was dropped when the issue #99 retrofit cut the # composition sentence whose YAML fold produced that target. Per the rule above # it is removed, not skipped. -# shellcheck disable=SC2043 # one probe left by design -- the list shrinks as -# each fixture is retrofitted and reaches zero when `research` lands. Keeping the -# loop means removing the last entry is a one-line edit, not a restructure. -for probe in \ - "plugins/bin/.apm/skills/research/SKILL.md:neuledge-context"; do - probe_file="$REPO_ROOT/${probe%%:*}" - probe_name="${probe##*:}" - if [[ ! -f "$probe_file" ]]; then - fail "the probe fixture ${probe%%:*} no longer exists — this pin has become vacuous; update it and EXPECTED_DANGLING in tests/test-adr0020-targets.sh together" - continue - fi - # Captured, not piped: the script exits non-zero on these files and - # `set -o pipefail` would make the whole pipeline non-zero regardless of what - # grep found. - set +e - probe_out="$("$SCRIPT" "$probe_file" 2>&1)" - set -e - if [[ "$probe_out" == *"routes to '$probe_name'"* ]]; then - pass "detects the dangling '$probe_name' target in ${probe%%:*}" - else - fail "did not detect the dangling '$probe_name' target in ${probe%%:*}. If issue #100 retrofitted it, drop this probe and update EXPECTED_DANGLING in tests/test-adr0020-targets.sh; if a false-positive fix took a true positive with it, that is the regression this asserts." - fi -done +# +# The `neuledge-context` probe — the last one — went the same way in wave 3 of +# that retrofit, which deleted the boundary clause naming it. **The corpus now +# has zero dangling targets**, so this loop is removed entirely rather than left +# to iterate over an empty list. +# +# That is deliberate and follows the rule stated above. A loop over no probes +# produces no assertion while still returning success, which is the vacuous-pass +# shape this comment block exists to reject — it would make the suite look one +# test stronger than it is, exactly the complaint levelled at the old +# `skill-improve` SKIP branch. +# +# Nothing is lost. This file only ever checked that each member of the live +# dangling set is individually detected; tests/test-adr0020-targets.sh remains +# the authority on the set itself, and now pins it as EMPTY, which is what +# catches a newly-authored clause naming a target that does not resolve. That +# file also carries synthetic fixtures built inside a real plugin tree, which +# exercise the detection path without depending on the corpus staying broken. +# +# If a real dangling target ever reappears, add its probe back here. echo "" echo "Results: $PASS passed, $FAIL failed" -- 2.43.0 From f03bfa8d246d16fdfd696e052ff18871b083a0b5 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:25 +0000 Subject: [PATCH 26/89] refactor(bin): retrofit prototype to the ADR-0020 context contract Description 426 -> 286 chars, with a boundary clause added. The body was already compliant at 467 words and is untouched. A clean-context audit caught the first pass trading away the LOGIC branch's routing vocabulary for characters it did not need to save: both 'data model' and 'business logic' had gone, though LOGIC.md defines its own scope with exactly those words. Restored, so 'does this data model feel right?' routes here again. Accepts 286 over the 250 target -- the hard tier is 400, and the alternative was leaving half the dispatch reachable by one phrase. Leaves LOGIC.md and UI.md at the skill root; moving them into references/ is tracked as #114. Refs #99 --- plugins/bin/.apm/skills/prototype/SKILL.md | 6 +++++- plugins/bin/skills/prototype/SKILL.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/bin/.apm/skills/prototype/SKILL.md b/plugins/bin/.apm/skills/prototype/SKILL.md index 84478e0..e938808 100644 --- a/plugins/bin/.apm/skills/prototype/SKILL.md +++ b/plugins/bin/.apm/skills/prototype/SKILL.md @@ -1,6 +1,10 @@ --- name: prototype -description: Build a throwaway prototype to flush out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs". +description: > + Use when the user wants a throwaway prototype to answer a design question about + a data model, state machine or business logic, or to mock up a UI in several + variations, as in "try a few designs". Not production code -> `tdd`. Not + talking a design through without building -> `grill-me`. --- # Prototype diff --git a/plugins/bin/skills/prototype/SKILL.md b/plugins/bin/skills/prototype/SKILL.md index 84478e0..e938808 100644 --- a/plugins/bin/skills/prototype/SKILL.md +++ b/plugins/bin/skills/prototype/SKILL.md @@ -1,6 +1,10 @@ --- name: prototype -description: Build a throwaway prototype to flush out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs". +description: > + Use when the user wants a throwaway prototype to answer a design question about + a data model, state machine or business logic, or to mock up a UI in several + variations, as in "try a few designs". Not production code -> `tdd`. Not + talking a design through without building -> `grill-me`. --- # Prototype -- 2.43.0 From 00c1e6b305be5563e96ffa539fd51bf263c1902b Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:26 +0000 Subject: [PATCH 27/89] refactor(bin): retrofit diagnose to the ADR-0020 context contract Body 1126 -> 808 words, clearing the FAIL tier, and description 290 -> 220 chars. Phase 1's depth moves to references/feedback-loops.md; the six-phase spine stays in the body, since a linear procedure is not a dispatch case. The description rewrite was not originally in scope, which was an error: adding a mandatory boundary clause to a 290-char description cannot land under 400. The dropped capability chain was also inaccurate -- it named 'minimise' as a phase that does not exist while omitting the one phase the body calls 'This is the skill'. A clean-context audit found no text lost but three reachability defects, all fixed: content stranded behind an inverted trigger, Phase 2's reproduction-rate threshold defined only in a file that path never loaded, and a script path that did not resolve from the file carrying it. The two reference files are merged into one, since the split is what created the first two. Refs #99 --- plugins/bin/.apm/skills/diagnose/SKILL.md | 46 ++++--------------- .../diagnose/references/feedback-loops.md | 40 ++++++++++++++++ plugins/bin/skills/diagnose/SKILL.md | 46 ++++--------------- .../diagnose/references/feedback-loops.md | 40 ++++++++++++++++ 4 files changed, 98 insertions(+), 74 deletions(-) create mode 100644 plugins/bin/.apm/skills/diagnose/references/feedback-loops.md create mode 100644 plugins/bin/skills/diagnose/references/feedback-loops.md diff --git a/plugins/bin/.apm/skills/diagnose/SKILL.md b/plugins/bin/.apm/skills/diagnose/SKILL.md index ed55bda..3de68ca 100644 --- a/plugins/bin/.apm/skills/diagnose/SKILL.md +++ b/plugins/bin/.apm/skills/diagnose/SKILL.md @@ -1,6 +1,9 @@ --- name: diagnose -description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +description: > + Use when the user says "diagnose this" or "debug this", reports something + broken, throwing, or failing, or says something got slow. Not filing or + triaging a reported bug -> `triage`. Not test-first feature work -> `tdd`. --- # Diagnose @@ -15,40 +18,9 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -### Ways to construct one — try them in roughly this order +Read `references/feedback-loops.md` — even if you already have a signal. Ten ways to build a loop ordered by cost, how to sharpen the one you have, and what to do when the bug resists reproduction. An unsharpened loop is usually not good enough yet. -1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. -2. **Curl / HTTP script** against a running dev server. -3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. -4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. -5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. -6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. -7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. -8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. -9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. - -Build the right feedback loop, and the bug is 90% fixed. - -### Iterate on the loop itself - -Treat the loop as a product. Once you have _a_ loop, ask: - -- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) -- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) -- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) - -A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. - -### Non-deterministic bugs - -The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. - -### When you genuinely cannot build a loop - -Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. - -Do not proceed to Phase 2 until you have a loop you believe in. +Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. ## Phase 2 — Reproduce @@ -57,7 +29,7 @@ Run the loop. Watch the bug appear. Confirm: - [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. -- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] The failure is reproducible across multiple runs. If it is intermittent, `references/feedback-loops.md` defines the rate high enough to debug against — go back to Phase 1 and raise it. - [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. Do not proceed until you reproduce the bug. @@ -98,11 +70,11 @@ A correct seam is one where the test exercises the **real bug pattern** as it oc If a correct seam exists: -1. Turn the minimised repro into a failing test at that seam. +1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. 3. Apply the fix. 4. Watch it pass. -5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. +5. Re-run the Phase 1 feedback loop against the original, un-narrowed scenario. ## Phase 6 — Cleanup + post-mortem diff --git a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md new file mode 100644 index 0000000..899c95a --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md @@ -0,0 +1,40 @@ +# Constructing and sharpening a feedback loop + +A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for the bug. Build the right one and the bug is 90% fixed. This file covers the whole arc: building a loop, sharpening one you already have, and escalating when the bug resists reproduction. + +## Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `../scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +## Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +## Intermittent bugs — raise the reproduction rate + +If the loop only sometimes fails, the goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +## When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: + +- access to whatever environment reproduces it, +- a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or +- permission to add temporary production instrumentation. + +Do **not** proceed to hypothesise without a loop. A hypothesis you cannot falsify against a signal is a guess, and the fix that follows it is unverifiable. diff --git a/plugins/bin/skills/diagnose/SKILL.md b/plugins/bin/skills/diagnose/SKILL.md index ed55bda..3de68ca 100644 --- a/plugins/bin/skills/diagnose/SKILL.md +++ b/plugins/bin/skills/diagnose/SKILL.md @@ -1,6 +1,9 @@ --- name: diagnose -description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +description: > + Use when the user says "diagnose this" or "debug this", reports something + broken, throwing, or failing, or says something got slow. Not filing or + triaging a reported bug -> `triage`. Not test-first feature work -> `tdd`. --- # Diagnose @@ -15,40 +18,9 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -### Ways to construct one — try them in roughly this order +Read `references/feedback-loops.md` — even if you already have a signal. Ten ways to build a loop ordered by cost, how to sharpen the one you have, and what to do when the bug resists reproduction. An unsharpened loop is usually not good enough yet. -1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. -2. **Curl / HTTP script** against a running dev server. -3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. -4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. -5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. -6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. -7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. -8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. -9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. - -Build the right feedback loop, and the bug is 90% fixed. - -### Iterate on the loop itself - -Treat the loop as a product. Once you have _a_ loop, ask: - -- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) -- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) -- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) - -A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. - -### Non-deterministic bugs - -The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. - -### When you genuinely cannot build a loop - -Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. - -Do not proceed to Phase 2 until you have a loop you believe in. +Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. ## Phase 2 — Reproduce @@ -57,7 +29,7 @@ Run the loop. Watch the bug appear. Confirm: - [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. -- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] The failure is reproducible across multiple runs. If it is intermittent, `references/feedback-loops.md` defines the rate high enough to debug against — go back to Phase 1 and raise it. - [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. Do not proceed until you reproduce the bug. @@ -98,11 +70,11 @@ A correct seam is one where the test exercises the **real bug pattern** as it oc If a correct seam exists: -1. Turn the minimised repro into a failing test at that seam. +1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. 3. Apply the fix. 4. Watch it pass. -5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. +5. Re-run the Phase 1 feedback loop against the original, un-narrowed scenario. ## Phase 6 — Cleanup + post-mortem diff --git a/plugins/bin/skills/diagnose/references/feedback-loops.md b/plugins/bin/skills/diagnose/references/feedback-loops.md new file mode 100644 index 0000000..899c95a --- /dev/null +++ b/plugins/bin/skills/diagnose/references/feedback-loops.md @@ -0,0 +1,40 @@ +# Constructing and sharpening a feedback loop + +A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for the bug. Build the right one and the bug is 90% fixed. This file covers the whole arc: building a loop, sharpening one you already have, and escalating when the bug resists reproduction. + +## Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `../scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +## Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +## Intermittent bugs — raise the reproduction rate + +If the loop only sometimes fails, the goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +## When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: + +- access to whatever environment reproduces it, +- a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or +- permission to add temporary production instrumentation. + +Do **not** proceed to hypothesise without a loop. A hypothesis you cannot falsify against a signal is a guess, and the fix that follows it is unverifiable. -- 2.43.0 From e42c05529497558ef192c7649bdfa69d5a17fca0 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:48 +0000 Subject: [PATCH 28/89] refactor(core): retrofit agentsmd-author to the ADR-0020 context contract Description 960 -> 244 chars, body 470 -> 452 words, Gotchas 36% -> 22%. Both composition notes move to README.md, which already carried them. Four of five Gotchas were paraphrases of the step below them and were deleted with their force folded back into that step. A clean-context audit overturned the fifth deletion: the provider-file prohibition was strictly broader than Step 4, so it was never a paraphrase, and Step 4's 'don't rewrite it yourself' is attached to the if-duplicates branch. With Write and Edit granted, a provider file that was merely stale had nothing forbidding an edit. Restored as an unconditional Gotcha, read before any step writes. Also restores a concrete indirect trigger. The retrofit had replaced two with the meta-statement 'even when they don't name the file', which claims an indirect trigger exists rather than being one -- and users asking to document a repo for AI tools have no reason to know the filename. Refs #99 --- .../core/.apm/skills/agentsmd-author/SKILL.md | 33 ++++++------------- plugins/core/skills/agentsmd-author/SKILL.md | 33 ++++++------------- 2 files changed, 20 insertions(+), 46 deletions(-) diff --git a/plugins/core/.apm/skills/agentsmd-author/SKILL.md b/plugins/core/.apm/skills/agentsmd-author/SKILL.md index a38604d..6dc9d1f 100644 --- a/plugins/core/.apm/skills/agentsmd-author/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-author/SKILL.md @@ -1,20 +1,10 @@ --- name: agentsmd-author description: > - Use when the user wants to create or update a repo's AGENTS.md file - ("write an AGENTS.md for this repo", "add setup/test instructions for - agents", "update AGENTS.md", "give this package its own AGENTS.md") — even - if they don't name the file explicitly, e.g. "document this for AI coding - tools" or "make sure agents know how to run tests here". Writes/updates - AGENTS.md by exploring the target repo for real build, test, lint, and - style conventions — never invents commands. Supports nested monorepo - placement (a subdirectory can get its own AGENTS.md following - nearest-file-wins precedence). Closes every run by invoking agentsmd-audit - inline, and calls provider-adapter-author when an existing provider file - (CLAUDE.md, etc.) now duplicates what AGENTS.md owns. Do not use to review - an existing AGENTS.md without changing it — use agentsmd-audit instead. Do - not use to convert CLAUDE.md/.cursor/rules into a thin adapter — use - provider-adapter-author instead. + Use when the user wants a repo's AGENTS.md written or updated, root or + nested, including "document this for AI coding tools". Writes only verified + conventions. Not review-only -> `agentsmd-audit`. Not for CLAUDE.md -> + `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: category: docs @@ -22,16 +12,13 @@ metadata: - agents-md-official - context7-websites-agents-md - context7-agentsmd-agents-md - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Never invent a command. Every line under a setup/test/build section must come from something you actually found in the repo (`package.json` scripts, a `Makefile` target, a CI workflow step, a README). If you can't verify a command, don't include it. -- AGENTS.md has no required schema — don't force every common-sections-checklist heading into every repo. Include only sections that reflect something real about this repo; a thin, accurate file beats a padded, generic one. -- Nested placement is for genuinely different conventions, not convenience. Only create a subdirectory AGENTS.md when that subtree has its own build tool, stack, or conventions distinct from the root — otherwise you're duplicating content the root already covers, which the nearest-file-wins rule doesn't merge back together. -- This skill never touches CLAUDE.md, `.cursor/rules/*.mdc`, `copilot-instructions.md`, or similar provider files directly — that's `provider-adapter-author`'s job. Detect and hand off; don't reconcile it yourself. -- This skill never audits on its own judgment — the closing `agentsmd-audit` invocation is mandatory, not optional, even when the change looks trivial. +- Never write to a provider file yourself, in any circumstance: `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md` and their equivalents are `provider-adapter-author`'s to own. That holds even when the user asks for one in the same breath as AGENTS.md, and even when the file is merely stale or missing a pointer rather than duplicating anything. Detect it and hand off. ## Step 1 — Explore the target repo @@ -40,17 +27,17 @@ Before writing anything, gather real facts: package manager and scripts (`packag ## Step 2 — Decide placement - No `AGENTS.md` at the repo root yet → create one there first, covering whole-repo conventions. -- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Don't repeat root-level content — the nearest-file-wins rule means the nested file is read alone, not merged with the root. +- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Convenience is not a reason to create one — without a distinct stack you are duplicating content the root already covers. **Don't repeat root-level content** in a nested file: the nearest-file-wins rule means it is read alone, never merged back with the root. - Otherwise → update the existing file(s) in place. ## Step 3 — Write or update -Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. +AGENTS.md has no required schema. Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists, because a thin accurate file beats a padded generic one. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile — don't rewrite it yourself. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. diff --git a/plugins/core/skills/agentsmd-author/SKILL.md b/plugins/core/skills/agentsmd-author/SKILL.md index a38604d..6dc9d1f 100644 --- a/plugins/core/skills/agentsmd-author/SKILL.md +++ b/plugins/core/skills/agentsmd-author/SKILL.md @@ -1,20 +1,10 @@ --- name: agentsmd-author description: > - Use when the user wants to create or update a repo's AGENTS.md file - ("write an AGENTS.md for this repo", "add setup/test instructions for - agents", "update AGENTS.md", "give this package its own AGENTS.md") — even - if they don't name the file explicitly, e.g. "document this for AI coding - tools" or "make sure agents know how to run tests here". Writes/updates - AGENTS.md by exploring the target repo for real build, test, lint, and - style conventions — never invents commands. Supports nested monorepo - placement (a subdirectory can get its own AGENTS.md following - nearest-file-wins precedence). Closes every run by invoking agentsmd-audit - inline, and calls provider-adapter-author when an existing provider file - (CLAUDE.md, etc.) now duplicates what AGENTS.md owns. Do not use to review - an existing AGENTS.md without changing it — use agentsmd-audit instead. Do - not use to convert CLAUDE.md/.cursor/rules into a thin adapter — use - provider-adapter-author instead. + Use when the user wants a repo's AGENTS.md written or updated, root or + nested, including "document this for AI coding tools". Writes only verified + conventions. Not review-only -> `agentsmd-audit`. Not for CLAUDE.md -> + `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: category: docs @@ -22,16 +12,13 @@ metadata: - agents-md-official - context7-websites-agents-md - context7-agentsmd-agents-md - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Never invent a command. Every line under a setup/test/build section must come from something you actually found in the repo (`package.json` scripts, a `Makefile` target, a CI workflow step, a README). If you can't verify a command, don't include it. -- AGENTS.md has no required schema — don't force every common-sections-checklist heading into every repo. Include only sections that reflect something real about this repo; a thin, accurate file beats a padded, generic one. -- Nested placement is for genuinely different conventions, not convenience. Only create a subdirectory AGENTS.md when that subtree has its own build tool, stack, or conventions distinct from the root — otherwise you're duplicating content the root already covers, which the nearest-file-wins rule doesn't merge back together. -- This skill never touches CLAUDE.md, `.cursor/rules/*.mdc`, `copilot-instructions.md`, or similar provider files directly — that's `provider-adapter-author`'s job. Detect and hand off; don't reconcile it yourself. -- This skill never audits on its own judgment — the closing `agentsmd-audit` invocation is mandatory, not optional, even when the change looks trivial. +- Never write to a provider file yourself, in any circumstance: `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md` and their equivalents are `provider-adapter-author`'s to own. That holds even when the user asks for one in the same breath as AGENTS.md, and even when the file is merely stale or missing a pointer rather than duplicating anything. Detect it and hand off. ## Step 1 — Explore the target repo @@ -40,17 +27,17 @@ Before writing anything, gather real facts: package manager and scripts (`packag ## Step 2 — Decide placement - No `AGENTS.md` at the repo root yet → create one there first, covering whole-repo conventions. -- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Don't repeat root-level content — the nearest-file-wins rule means the nested file is read alone, not merged with the root. +- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Convenience is not a reason to create one — without a distinct stack you are duplicating content the root already covers. **Don't repeat root-level content** in a nested file: the nearest-file-wins rule means it is read alone, never merged back with the root. - Otherwise → update the existing file(s) in place. ## Step 3 — Write or update -Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. +AGENTS.md has no required schema. Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists, because a thin accurate file beats a padded generic one. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing. ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile — don't rewrite it yourself. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. -- 2.43.0 From f0526b310df7bcb96bb79f304319aeef55155490 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:49 +0000 Subject: [PATCH 29/89] refactor(core): retrofit agentsmd-audit to the ADR-0020 context contract Description 944 -> 322 chars, Gotchas 36% -> 22%. The composition note moves to README.md. Restores the hand-edit trigger, which a clean-context audit found had no other caller: agentsmd-author owns the post-authoring invocation, but a hand-edit has no author skill in the loop, so nothing invoked the audit at all. It survived only in README.md, which neither the router nor the invoked agent loads. That is the path on which a human pastes a credential into AGENTS.md. The first pass dropped it against a measured budget of '~9 spare chars'. The real cost was ~49, and 250 is the SUGGESTION tier, not a ceiling -- the gate fails at 400. Ships at 322 with one advisory line. Names the three audit dimensions in the capability clause, recovering routing for 'does my AGENTS.md leak credentials', and qualifies the 'is this AGENTS.md safe to commit' phrasing, whose pronoun had no antecedent inside the quoted string. Refs #99 --- .../core/.apm/skills/agentsmd-audit/README.md | 3 +++ .../core/.apm/skills/agentsmd-audit/SKILL.md | 26 ++++++------------- plugins/core/skills/agentsmd-audit/README.md | 3 +++ plugins/core/skills/agentsmd-audit/SKILL.md | 26 ++++++------------- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/plugins/core/.apm/skills/agentsmd-audit/README.md b/plugins/core/.apm/skills/agentsmd-audit/README.md index d863d94..658df6f 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/README.md +++ b/plugins/core/.apm/skills/agentsmd-audit/README.md @@ -14,6 +14,9 @@ Runs a single combined pass across every AGENTS.md file in a repo (root and any Provide the path to the repo root to audit when invoking. +Also invoke it proactively after `agentsmd-author` creates or updates an AGENTS.md, or after a +hand-edit made outside `agentsmd-author` — the audit is what confirms the result is safe to commit. + ## Files | File | Purpose | diff --git a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md index d8a4a34..8d578d4 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md @@ -1,20 +1,11 @@ --- name: agentsmd-audit description: > - Use when the user wants to review a repo's AGENTS.md file, says "audit this - AGENTS.md", "check my AGENTS.md", "is this AGENTS.md any good", or wants to - know if AGENTS.md is safe to commit — even if they don't use the word - "audit". Also invoke proactively after agentsmd-author creates or updates - AGENTS.md, or after a hand-edit made outside agentsmd-author. Audits a - target repo's AGENTS.md file(s) — root and any nested monorepo files — for - embedded secrets/credentials, structural completeness against the - agents.md common-sections checklist, and drift (referenced commands or - paths that no longer resolve against the repo). Produces a compact - findings report (findings only, no PASS noise) with Why and Fix per - finding. Do not use to audit CLAUDE.md, .cursor/rules, or other - provider-specific adapter files — that's provider-adapter-author's - self-contained concern. Do not use to fix or write AGENTS.md content — use - agentsmd-author instead. + Use when the user wants a repo's AGENTS.md audited — "audit this AGENTS.md", + "is this AGENTS.md safe to commit" — or after a hand-edit outside + `agentsmd-author`. Reports secrets, structure and drift; never edits. + Not for CLAUDE.md or provider files -> `provider-adapter-author`. + Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: category: docs @@ -23,14 +14,13 @@ metadata: - context7-websites-agents-md - context7-agentsmd-agents-md - governance-secrets-hard-prohibition - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Always run all three checks — this skill does a single combined pass, not staged/gated passes. Don't skip structure or drift checks just because a secrets FAIL was found. -- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. If one exists and duplicates AGENTS.md content, that's `provider-adapter-author`'s concern, not this skill's. -- A missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. - Gather findings internally; don't narrate PASS/FAIL per check as you go — surface them only in the final report. ## Step 1 — Run the validators @@ -41,7 +31,7 @@ bash scripts/validate-structure.sh <repo-root> bash scripts/validate-drift.sh <repo-root> ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report diff --git a/plugins/core/skills/agentsmd-audit/README.md b/plugins/core/skills/agentsmd-audit/README.md index d863d94..658df6f 100644 --- a/plugins/core/skills/agentsmd-audit/README.md +++ b/plugins/core/skills/agentsmd-audit/README.md @@ -14,6 +14,9 @@ Runs a single combined pass across every AGENTS.md file in a repo (root and any Provide the path to the repo root to audit when invoking. +Also invoke it proactively after `agentsmd-author` creates or updates an AGENTS.md, or after a +hand-edit made outside `agentsmd-author` — the audit is what confirms the result is safe to commit. + ## Files | File | Purpose | diff --git a/plugins/core/skills/agentsmd-audit/SKILL.md b/plugins/core/skills/agentsmd-audit/SKILL.md index d8a4a34..8d578d4 100644 --- a/plugins/core/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/skills/agentsmd-audit/SKILL.md @@ -1,20 +1,11 @@ --- name: agentsmd-audit description: > - Use when the user wants to review a repo's AGENTS.md file, says "audit this - AGENTS.md", "check my AGENTS.md", "is this AGENTS.md any good", or wants to - know if AGENTS.md is safe to commit — even if they don't use the word - "audit". Also invoke proactively after agentsmd-author creates or updates - AGENTS.md, or after a hand-edit made outside agentsmd-author. Audits a - target repo's AGENTS.md file(s) — root and any nested monorepo files — for - embedded secrets/credentials, structural completeness against the - agents.md common-sections checklist, and drift (referenced commands or - paths that no longer resolve against the repo). Produces a compact - findings report (findings only, no PASS noise) with Why and Fix per - finding. Do not use to audit CLAUDE.md, .cursor/rules, or other - provider-specific adapter files — that's provider-adapter-author's - self-contained concern. Do not use to fix or write AGENTS.md content — use - agentsmd-author instead. + Use when the user wants a repo's AGENTS.md audited — "audit this AGENTS.md", + "is this AGENTS.md safe to commit" — or after a hand-edit outside + `agentsmd-author`. Reports secrets, structure and drift; never edits. + Not for CLAUDE.md or provider files -> `provider-adapter-author`. + Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: category: docs @@ -23,14 +14,13 @@ metadata: - context7-websites-agents-md - context7-agentsmd-agents-md - governance-secrets-hard-prohibition - version: "0.1.1" + version: "0.1.2" --- ## Gotchas - Always run all three checks — this skill does a single combined pass, not staged/gated passes. Don't skip structure or drift checks just because a secrets FAIL was found. -- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. If one exists and duplicates AGENTS.md content, that's `provider-adapter-author`'s concern, not this skill's. -- A missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. - Gather findings internally; don't narrate PASS/FAIL per check as you go — surface them only in the final report. ## Step 1 — Run the validators @@ -41,7 +31,7 @@ bash scripts/validate-structure.sh <repo-root> bash scripts/validate-drift.sh <repo-root> ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report -- 2.43.0 From c59e4bf0c57e412622c5e1731e83004b5a3fb3dc Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:06:50 +0000 Subject: [PATCH 30/89] refactor(core): retrofit provider-adapter-author to the ADR-0020 contract Description 833 -> 239 chars, body 370 -> 387 words, Gotchas 41% -> 20%. The file-type enumeration moves to a new references/provider-matrix.md; the composition note was already in README.md. Three defects a clean-context audit found, all fixed: The 'never edits AGENTS.md' prohibition had become a justification clause on the false branch of a conditional, so the common path never read a sentence binding it. That matters because the bundled validator's own remediation text tells the agent to move content into AGENTS.md, so a size FAIL actively invited the prohibited edit. Restored as a standing imperative, plus a counter at the step where the trap fires. A Gotcha asserted that validate-adapter.sh fails without --no-import-syntax. The flag is a no-op -- both branches reduce to the same expression. Reverted to an instruction; the script defect is #115. The boundary clauses used pronouns to dodge the #110 regex, and 'Not auditing it' resolved to CLAUDE.md as readily as to AGENTS.md -- routing 'audit my CLAUDE.md' to a skill whose own description declines it. Refs #99 --- .../skills/provider-adapter-author/README.md | 1 + .../skills/provider-adapter-author/SKILL.md | 32 ++++++++----------- .../references/provider-matrix.md | 23 +++++++++++++ .../references/sources.md | 2 +- .../skills/provider-adapter-author/README.md | 1 + .../skills/provider-adapter-author/SKILL.md | 32 ++++++++----------- .../references/provider-matrix.md | 23 +++++++++++++ .../references/sources.md | 2 +- 8 files changed, 78 insertions(+), 38 deletions(-) create mode 100644 plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md create mode 100644 plugins/core/skills/provider-adapter-author/references/provider-matrix.md diff --git a/plugins/core/.apm/skills/provider-adapter-author/README.md b/plugins/core/.apm/skills/provider-adapter-author/README.md index 9dc0652..1f665e0 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/README.md @@ -23,6 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 6629822..2de7fe5 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -1,36 +1,28 @@ --- name: provider-adapter-author description: > - Use when the user wants to convert a provider-specific AI instruction file - (CLAUDE.md, .cursor/rules/*.mdc, copilot-instructions.md, etc.) into a - thin adapter that defers to a repo's AGENTS.md — e.g. "reduce duplication - between CLAUDE.md and AGENTS.md", "make CLAUDE.md just import AGENTS.md" - — even if the pattern isn't named explicitly. Also invoke when - agentsmd-author detects an existing provider file overlapping with - AGENTS.md it just wrote. Detects redundant content in a provider file - relative to AGENTS.md and rewrites it down to a minimal reference (an - `@AGENTS.md`-style import where supported, or a text pointer otherwise) - plus genuinely provider-specific additions. Self-validates via a bundled - deterministic script before finishing. Do not use to write or audit - AGENTS.md itself — use agentsmd-author or agentsmd-audit. + Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) + duplicating the repo's AGENTS.md should be cut to a thin adapter. + Not writing the AGENTS file -> `agentsmd-author`. + Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write metadata: category: docs source_keys: - adr-0002-0003-two-tier-claude-md - version: "0.1.0" + version: "0.1.1" --- ## Gotchas -- Not every provider supports cross-file imports. Claude Code does — a `CLAUDE.md` can consist of nothing but one or more `@path` lines (e.g. `@AGENTS.md`), with no other content required. Cursor's `.cursor/rules/*.mdc` and GitHub Copilot's `copilot-instructions.md` have no native import mechanism as of current tooling — for those, "thin" means a short text pointer to AGENTS.md plus only what that tool actually needs, not a literal import line. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for these providers. -- This skill never creates or edits `AGENTS.md` itself. If the target repo has no `AGENTS.md` yet, stop and point the user to `agentsmd-author` first — there's nothing to adapt to. -- Only strip content from the provider file that's genuinely redundant with AGENTS.md. Provider-specific material (IDE settings, tool-only syntax, model-specific instructions) stays — the goal is thin, not empty. +- Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. - Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. ## Step 1 — Detect -Look for known provider instruction files in the target repo: `CLAUDE.md` (repo root, and any deployed copies), `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, and similar tool-specific files. Confirm `AGENTS.md` exists at the repo root — if not, stop and tell the user to run `agentsmd-author` first. +Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. + +Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell the user to run `agentsmd-author` first — there is nothing to adapt to. ## Step 2 — Diff and rewrite @@ -39,6 +31,10 @@ Read the provider file and `AGENTS.md` side by side. Separate the provider file' - **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. + +Strip only what is genuinely redundant. Provider-specific material stays even when it is short — the goal is thin, not empty. + ## Step 3 — Self-validate Run the bundled check before finishing — this is the skill's own closeout gate; there is no separate paired audit skill for this concern: @@ -47,7 +43,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. The size `FAIL` advises moving provider-agnostic content into `AGENTS.md`; disregard that half of its wording and delete the redundant lines instead. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md new file mode 100644 index 0000000..2d684cf --- /dev/null +++ b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md @@ -0,0 +1,23 @@ +--- +source_keys: + - adr-0002-0003-two-tier-claude-md +--- + +# Known provider instruction files + +Which files to look for when detecting a provider-specific instruction file, whether each provider +resolves a cross-file import, and what a thin adapter therefore looks like for it. + +| Provider | File(s) | Import syntax | Thin adapter shape | Validator flag | +|---|---|---|---|---| +| Claude Code | `CLAUDE.md` at the repo root, plus any deployed copies | Yes — `@path` lines, e.g. `@AGENTS.md` | One or more `@` import lines; no other content is required | none | +| Cursor | `.cursor/rules/*.mdc` | No | A short sentence pointing at `AGENTS.md`, plus the rule's own frontmatter and provider-specific body | `--no-import-syntax` | +| GitHub Copilot | `.github/copilot-instructions.md` | No | A short sentence pointing at `AGENTS.md`, plus Copilot-only instructions | `--no-import-syntax` | +| Anything else | tool-specific instruction file at whatever path the tool documents | Assume no | Text pointer, as above | `--no-import-syntax` | + +A provider not listed here is not evidence it has an import mechanism. Confirm against that tool's +own documentation before emitting an `@`-style line; an unresolved import reads as literal text and +silently drops every rule the adapter was supposed to defer to. + +Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts +independently against the same `AGENTS.md`. diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/sources.md b/plugins/core/.apm/skills/provider-adapter-author/references/sources.md index 7b7071a..654c51d 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/references/sources.md +++ b/plugins/core/.apm/skills/provider-adapter-author/references/sources.md @@ -5,5 +5,5 @@ - **URL:** (in-repo precedent — not an external source or plugin research corpus entry) - **Description:** This repo's own two-tier CLAUDE.md/AGENTS.md pattern: AGENTS.md is the provider-agnostic source of always-on rules; provider-specific files (CLAUDE.md) become thin adapters that import it (`@AGENTS.md` plus provider-specific additions). Grounds this skill's entire adapter-conversion design — the "thin adapter" shape, the `@`-import convention, and the size/duplication expectations enforced by `scripts/validate-adapter.sh`. - **Research doc:** docs/adr/0002-two-tier-claude-md.md, docs/adr/0003-agents-md-provider-agnostic-entry-point.md, providers/claude-code/CLAUDE.md (in-repo ADRs and a live example, not a plugin research corpus entry; referenced here since this skill's design is modeled directly on an existing implementation rather than external research) -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/provider-matrix.md - **Status:** `extracted` diff --git a/plugins/core/skills/provider-adapter-author/README.md b/plugins/core/skills/provider-adapter-author/README.md index 9dc0652..1f665e0 100644 --- a/plugins/core/skills/provider-adapter-author/README.md +++ b/plugins/core/skills/provider-adapter-author/README.md @@ -23,6 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 6629822..2de7fe5 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -1,36 +1,28 @@ --- name: provider-adapter-author description: > - Use when the user wants to convert a provider-specific AI instruction file - (CLAUDE.md, .cursor/rules/*.mdc, copilot-instructions.md, etc.) into a - thin adapter that defers to a repo's AGENTS.md — e.g. "reduce duplication - between CLAUDE.md and AGENTS.md", "make CLAUDE.md just import AGENTS.md" - — even if the pattern isn't named explicitly. Also invoke when - agentsmd-author detects an existing provider file overlapping with - AGENTS.md it just wrote. Detects redundant content in a provider file - relative to AGENTS.md and rewrites it down to a minimal reference (an - `@AGENTS.md`-style import where supported, or a text pointer otherwise) - plus genuinely provider-specific additions. Self-validates via a bundled - deterministic script before finishing. Do not use to write or audit - AGENTS.md itself — use agentsmd-author or agentsmd-audit. + Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) + duplicating the repo's AGENTS.md should be cut to a thin adapter. + Not writing the AGENTS file -> `agentsmd-author`. + Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write metadata: category: docs source_keys: - adr-0002-0003-two-tier-claude-md - version: "0.1.0" + version: "0.1.1" --- ## Gotchas -- Not every provider supports cross-file imports. Claude Code does — a `CLAUDE.md` can consist of nothing but one or more `@path` lines (e.g. `@AGENTS.md`), with no other content required. Cursor's `.cursor/rules/*.mdc` and GitHub Copilot's `copilot-instructions.md` have no native import mechanism as of current tooling — for those, "thin" means a short text pointer to AGENTS.md plus only what that tool actually needs, not a literal import line. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for these providers. -- This skill never creates or edits `AGENTS.md` itself. If the target repo has no `AGENTS.md` yet, stop and point the user to `agentsmd-author` first — there's nothing to adapt to. -- Only strip content from the provider file that's genuinely redundant with AGENTS.md. Provider-specific material (IDE settings, tool-only syntax, model-specific instructions) stays — the goal is thin, not empty. +- Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. - Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. ## Step 1 — Detect -Look for known provider instruction files in the target repo: `CLAUDE.md` (repo root, and any deployed copies), `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, and similar tool-specific files. Confirm `AGENTS.md` exists at the repo root — if not, stop and tell the user to run `agentsmd-author` first. +Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. + +Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell the user to run `agentsmd-author` first — there is nothing to adapt to. ## Step 2 — Diff and rewrite @@ -39,6 +31,10 @@ Read the provider file and `AGENTS.md` side by side. Separate the provider file' - **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. + +Strip only what is genuinely redundant. Provider-specific material stays even when it is short — the goal is thin, not empty. + ## Step 3 — Self-validate Run the bundled check before finishing — this is the skill's own closeout gate; there is no separate paired audit skill for this concern: @@ -47,7 +43,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. The size `FAIL` advises moving provider-agnostic content into `AGENTS.md`; disregard that half of its wording and delete the redundant lines instead. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md new file mode 100644 index 0000000..2d684cf --- /dev/null +++ b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md @@ -0,0 +1,23 @@ +--- +source_keys: + - adr-0002-0003-two-tier-claude-md +--- + +# Known provider instruction files + +Which files to look for when detecting a provider-specific instruction file, whether each provider +resolves a cross-file import, and what a thin adapter therefore looks like for it. + +| Provider | File(s) | Import syntax | Thin adapter shape | Validator flag | +|---|---|---|---|---| +| Claude Code | `CLAUDE.md` at the repo root, plus any deployed copies | Yes — `@path` lines, e.g. `@AGENTS.md` | One or more `@` import lines; no other content is required | none | +| Cursor | `.cursor/rules/*.mdc` | No | A short sentence pointing at `AGENTS.md`, plus the rule's own frontmatter and provider-specific body | `--no-import-syntax` | +| GitHub Copilot | `.github/copilot-instructions.md` | No | A short sentence pointing at `AGENTS.md`, plus Copilot-only instructions | `--no-import-syntax` | +| Anything else | tool-specific instruction file at whatever path the tool documents | Assume no | Text pointer, as above | `--no-import-syntax` | + +A provider not listed here is not evidence it has an import mechanism. Confirm against that tool's +own documentation before emitting an `@`-style line; an unresolved import reads as literal text and +silently drops every rule the adapter was supposed to defer to. + +Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts +independently against the same `AGENTS.md`. diff --git a/plugins/core/skills/provider-adapter-author/references/sources.md b/plugins/core/skills/provider-adapter-author/references/sources.md index 7b7071a..654c51d 100644 --- a/plugins/core/skills/provider-adapter-author/references/sources.md +++ b/plugins/core/skills/provider-adapter-author/references/sources.md @@ -5,5 +5,5 @@ - **URL:** (in-repo precedent — not an external source or plugin research corpus entry) - **Description:** This repo's own two-tier CLAUDE.md/AGENTS.md pattern: AGENTS.md is the provider-agnostic source of always-on rules; provider-specific files (CLAUDE.md) become thin adapters that import it (`@AGENTS.md` plus provider-specific additions). Grounds this skill's entire adapter-conversion design — the "thin adapter" shape, the `@`-import convention, and the size/duplication expectations enforced by `scripts/validate-adapter.sh`. - **Research doc:** docs/adr/0002-two-tier-claude-md.md, docs/adr/0003-agents-md-provider-agnostic-entry-point.md, providers/claude-code/CLAUDE.md (in-repo ADRs and a live example, not a plugin research corpus entry; referenced here since this skill's design is modeled directly on an existing implementation rather than external research) -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/provider-matrix.md - **Status:** `extracted` -- 2.43.0 From 45cd26045a2367d24a7f682bc8491940e21b48b1 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:07:15 +0000 Subject: [PATCH 31/89] refactor(lint): retrofit vale-config to the ADR-0020 context contract Description 657 -> 244 chars, body 504 -> 342 words, Gotchas 50% -> 24%. The 50% was the worst ratio in the corpus and a real inverted body, not the denominator artefact the ratio usually flags: half the body was a Gotchas section doing duty as reference material. A clean-context audit caught the split leaving a false statement behind. Gotcha 1's kernel lost the qualifier that only package styles need fetching, so it asserted that any style vale sync has not fetched fails -- contradicting the same file twice, since the built-in Vale style and any committed custom style are never fetched. An agent adding a custom style would have added a spurious Packages entry and broken vale sync outright. The qualifier is restored in the body rather than behind a fourth reference pointer. Also corrects the moved fixture's framing, which tabulated a control row under a heading claiming it came from a multi-line fixture. Refs #99 --- .../lint/.apm/skills/vale-config/README.md | 2 +- plugins/lint/.apm/skills/vale-config/SKILL.md | 21 +++++++------------ .../references/configuration-reference.md | 18 +++++++++++++++- plugins/lint/skills/vale-config/README.md | 2 +- plugins/lint/skills/vale-config/SKILL.md | 21 +++++++------------ .../references/configuration-reference.md | 18 +++++++++++++++- 6 files changed, 52 insertions(+), 30 deletions(-) diff --git a/plugins/lint/.apm/skills/vale-config/README.md b/plugins/lint/.apm/skills/vale-config/README.md index 2a5d0bc..b49138a 100644 --- a/plugins/lint/.apm/skills/vale-config/README.md +++ b/plugins/lint/.apm/skills/vale-config/README.md @@ -19,5 +19,5 @@ Describe what you want configured: initial setup, adding a third-party style pac | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/configuration-reference.md` | Full `.vale.ini` field and rule-header reference | +| `references/configuration-reference.md` | Full `.vale.ini` fields, rule-header fields, and frontmatter-scope behaviour | | `references/sources.md` | Research sources backing the Vale configuration guidance | diff --git a/plugins/lint/.apm/skills/vale-config/SKILL.md b/plugins/lint/.apm/skills/vale-config/SKILL.md index c5b192f..be534fb 100644 --- a/plugins/lint/.apm/skills/vale-config/SKILL.md +++ b/plugins/lint/.apm/skills/vale-config/SKILL.md @@ -2,27 +2,22 @@ name: vale-config description: > - Use when installing or configuring Vale, the cross-platform prose/style linter — setting up - .vale.ini, choosing a StylesPath, adding built-in, third-party, or custom styles, and activating - them per file glob via BasedOnStyles. Covers the setup side of Vale only: getting a project from - "no Vale config" to "vale sync runs clean and BasedOnStyles is wired up correctly". Use even if the - user doesn't say "Vale" explicitly — "set up prose linting", "lint our docs for style", "enforce a - vocabulary/terminology list in markdown" all apply. Do not use when the user wants to actually run - Vale and interpret its output on existing config — use vale-run for that. + Use when installing or configuring Vale, the prose/style linter — writing a `.vale.ini` whose + styles are fetched and actually activated — even when the user says only "set up prose + linting". Not running Vale on an existing config -> `vale-run`. metadata: category: lint - version: "0.1.0" + version: "0.1.1" source_keys: - context7-websites-vale-sh --- ## Gotchas -- Installing the `vale` binary installs no styles, but only *package* styles need fetching. A fresh `.vale.ini` naming a style in `BasedOnStyles` that is declared in `Packages` will fail or find nothing until `vale sync` downloads it. A built-in style (`Vale`) or a style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no sync. -- `.vale.ini` is order-sensitive: global (core) settings first, then the optional `[formats]` section, then glob sections (`[*]`, `[*.md]`, …). Settings in a glob section only apply to files matching that glob. -- `Packages` (top-level, fetched by `vale sync`) and `BasedOnStyles` (per-glob, activates) are separate keys — a style only lints files once it's in both. This is the step people forget. -- A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Confirmed against Vale 3.15.2 with a deliberately-bad fixture: a `>` folded block scalar, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars each yield 0 findings and exit 0, silently and with no error; a `|` literal block scalar spanning the same 2+ lines lints normally and exits 1. Do not assume `|` and `>` behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. +- A style in `BasedOnStyles` that is neither built-in nor already under `StylesPath` finds nothing until `vale sync` fetches it — a clean run is not proof anything linted. +- `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. +- A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -59,4 +54,4 @@ styles/ └── NoJargon.yml ``` -Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. See `references/configuration-reference.md` for the full rule header field table. +Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. If a rule needs a field beyond those two, read `references/configuration-reference.md` for the full rule header field table. diff --git a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md index d2bca35..7da0086 100644 --- a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md @@ -70,9 +70,25 @@ The underlying functions a rule's `extends` field can reference: `existence`, `s ## Built-in Style -Vale ships with a default `Vale` style containing four rules, usable without `vale sync`: +Only *package* styles need fetching. A style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no `vale sync`; the same is true of the built-in `Vale` style, which ships with the binary and contains four rules: - `Vale.Spelling` — spell-checks against Hunspell-compatible dictionaries in `<StylesPath>/config/dictionaries`. - `Vale.Terms` — enforces the project's accepted vocabulary terms. - `Vale.Avoid` — enforces the project's rejected vocabulary terms. - `Vale.Repetition` — flags repeated words (e.g. "the the"). + +## Frontmatter Scopes + +A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. + +Confirmed against Vale 3.15.2, multi-line forms spanning 2+ lines: + +| Frontmatter value form | Result | +|---|---| +| Single physical line (control) | Lints, exits 1 | +| `\|` literal block scalar | Lints, exits 1 | +| `>` folded block scalar | 0 findings, exits 0 | +| Plain (unquoted) continuation lines | 0 findings, exits 0 | +| Single- or double-quoted multi-line scalar | 0 findings, exits 0 | + +The silent cases produce no error of any kind, so a passing run is indistinguishable from a clean one. Do not assume a literal block scalar and a folded one behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. diff --git a/plugins/lint/skills/vale-config/README.md b/plugins/lint/skills/vale-config/README.md index 2a5d0bc..b49138a 100644 --- a/plugins/lint/skills/vale-config/README.md +++ b/plugins/lint/skills/vale-config/README.md @@ -19,5 +19,5 @@ Describe what you want configured: initial setup, adding a third-party style pac | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/configuration-reference.md` | Full `.vale.ini` field and rule-header reference | +| `references/configuration-reference.md` | Full `.vale.ini` fields, rule-header fields, and frontmatter-scope behaviour | | `references/sources.md` | Research sources backing the Vale configuration guidance | diff --git a/plugins/lint/skills/vale-config/SKILL.md b/plugins/lint/skills/vale-config/SKILL.md index c5b192f..be534fb 100644 --- a/plugins/lint/skills/vale-config/SKILL.md +++ b/plugins/lint/skills/vale-config/SKILL.md @@ -2,27 +2,22 @@ name: vale-config description: > - Use when installing or configuring Vale, the cross-platform prose/style linter — setting up - .vale.ini, choosing a StylesPath, adding built-in, third-party, or custom styles, and activating - them per file glob via BasedOnStyles. Covers the setup side of Vale only: getting a project from - "no Vale config" to "vale sync runs clean and BasedOnStyles is wired up correctly". Use even if the - user doesn't say "Vale" explicitly — "set up prose linting", "lint our docs for style", "enforce a - vocabulary/terminology list in markdown" all apply. Do not use when the user wants to actually run - Vale and interpret its output on existing config — use vale-run for that. + Use when installing or configuring Vale, the prose/style linter — writing a `.vale.ini` whose + styles are fetched and actually activated — even when the user says only "set up prose + linting". Not running Vale on an existing config -> `vale-run`. metadata: category: lint - version: "0.1.0" + version: "0.1.1" source_keys: - context7-websites-vale-sh --- ## Gotchas -- Installing the `vale` binary installs no styles, but only *package* styles need fetching. A fresh `.vale.ini` naming a style in `BasedOnStyles` that is declared in `Packages` will fail or find nothing until `vale sync` downloads it. A built-in style (`Vale`) or a style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no sync. -- `.vale.ini` is order-sensitive: global (core) settings first, then the optional `[formats]` section, then glob sections (`[*]`, `[*.md]`, …). Settings in a glob section only apply to files matching that glob. -- `Packages` (top-level, fetched by `vale sync`) and `BasedOnStyles` (per-glob, activates) are separate keys — a style only lints files once it's in both. This is the step people forget. -- A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Confirmed against Vale 3.15.2 with a deliberately-bad fixture: a `>` folded block scalar, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars each yield 0 findings and exit 0, silently and with no error; a `|` literal block scalar spanning the same 2+ lines lints normally and exits 1. Do not assume `|` and `>` behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. +- A style in `BasedOnStyles` that is neither built-in nor already under `StylesPath` finds nothing until `vale sync` fetches it — a clean run is not proof anything linted. +- `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. +- A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -59,4 +54,4 @@ styles/ └── NoJargon.yml ``` -Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. See `references/configuration-reference.md` for the full rule header field table. +Each rule file needs `extends` (the check it implements, e.g. `existence`) and `message` at minimum. Activate the style the same way as any other: add `MyStyle` to `BasedOnStyles` for the relevant glob. If a rule needs a field beyond those two, read `references/configuration-reference.md` for the full rule header field table. diff --git a/plugins/lint/skills/vale-config/references/configuration-reference.md b/plugins/lint/skills/vale-config/references/configuration-reference.md index d2bca35..7da0086 100644 --- a/plugins/lint/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/skills/vale-config/references/configuration-reference.md @@ -70,9 +70,25 @@ The underlying functions a rule's `extends` field can reference: `existence`, `s ## Built-in Style -Vale ships with a default `Vale` style containing four rules, usable without `vale sync`: +Only *package* styles need fetching. A style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no `vale sync`; the same is true of the built-in `Vale` style, which ships with the binary and contains four rules: - `Vale.Spelling` — spell-checks against Hunspell-compatible dictionaries in `<StylesPath>/config/dictionaries`. - `Vale.Terms` — enforces the project's accepted vocabulary terms. - `Vale.Avoid` — enforces the project's rejected vocabulary terms. - `Vale.Repetition` — flags repeated words (e.g. "the the"). + +## Frontmatter Scopes + +A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. + +Confirmed against Vale 3.15.2, multi-line forms spanning 2+ lines: + +| Frontmatter value form | Result | +|---|---| +| Single physical line (control) | Lints, exits 1 | +| `\|` literal block scalar | Lints, exits 1 | +| `>` folded block scalar | 0 findings, exits 0 | +| Plain (unquoted) continuation lines | 0 findings, exits 0 | +| Single- or double-quoted multi-line scalar | 0 findings, exits 0 | + +The silent cases produce no error of any kind, so a passing run is indistinguishable from a clean one. Do not assume a literal block scalar and a folded one behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone. -- 2.43.0 From ff187ef9fcb346dce32556c7c665d55a94932e0f Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:07:16 +0000 Subject: [PATCH 32/89] refactor(lint): retrofit vale-run to the ADR-0020 context contract Description 654 -> 294 chars, Gotchas 36% -> 19%. Body 698 -> 642 words: up from the first pass, because a clean-context audit found four defects whose fixes are net-additive text. The suppression-markup warning covered only one of the two paths that write it -- the list is case-based, so a recurring false positive goes straight to step 3 and never read step 2's warning. Hoisted above both. The CI-failure trigger, which the description advertises, had no path to the file holding its answer: the negative diagnosis 'if the alerts are warnings, Vale is not what failed the build' survived only in troubleshooting.md, which no CI-entered invocation loads. An agent would confidently prescribe --no-exit for a failure Vale never caused. The description had lost every prose-domain word -- no 'prose', no 'linter' -- while vale-config kept all of them, so 'check prose style' routed to the wrong skill of the pair. Accepts two soft SUGGESTIONs rather than dropping restored content; neither fails the gate. Refs #99 --- plugins/lint/.apm/skills/vale-run/README.md | 2 +- plugins/lint/.apm/skills/vale-run/SKILL.md | 29 ++++++++----------- .../vale-run/references/troubleshooting.md | 7 +++++ plugins/lint/skills/vale-run/README.md | 2 +- plugins/lint/skills/vale-run/SKILL.md | 29 ++++++++----------- .../vale-run/references/troubleshooting.md | 7 +++++ 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/plugins/lint/.apm/skills/vale-run/README.md b/plugins/lint/.apm/skills/vale-run/README.md index 8572456..459c68a 100644 --- a/plugins/lint/.apm/skills/vale-run/README.md +++ b/plugins/lint/.apm/skills/vale-run/README.md @@ -19,5 +19,5 @@ Describe what you want to lint and how (human-readable output, CI/JSON output, f | File | Purpose | |------|---------| | `SKILL.md` | Core invocation, key flags, output format guidance, false-positive triage order | -| `references/troubleshooting.md` | Inline suppression syntax, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | +| `references/troubleshooting.md` | Load when a rule appears not to apply, when writing inline suppression or spelling-ignore syntax, or when wiring Vale into pre-commit: resolved-config diagnostic (`vale ls-config`), format-specific suppression markup, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | | `references/sources.md` | Research provenance | diff --git a/plugins/lint/.apm/skills/vale-run/SKILL.md b/plugins/lint/.apm/skills/vale-run/SKILL.md index 11f443a..2ceb78d 100644 --- a/plugins/lint/.apm/skills/vale-run/SKILL.md +++ b/plugins/lint/.apm/skills/vale-run/SKILL.md @@ -1,17 +1,12 @@ --- name: vale-run description: > - Use when running Vale (a prose/style linter) against files or directories in an - already-configured project — one that already has a .vale.ini — and interpreting - or reporting its results: choosing an output format for humans vs. CI, filtering - by severity, handling Vale's exit codes in scripts, or resolving common runtime - issues like false positives and unexpected CI failures. Use even if the user - doesn't say "vale" explicitly, e.g. "lint the docs", "check prose style", "run - the style linter", "why is CI failing on the docs check". Do not use when the - project has no .vale.ini yet, or needs styles installed/configured — that's the - vale-config skill. + Use when running Vale (a prose/style linter) on a project that already has a + .vale.ini and acting on its output — even when the user does not say "Vale", + as in "lint the docs", "check prose style", or "why is CI failing on the docs + check". Not setting up Vale config or styles -> `vale-config`. metadata: - version: "0.1.1" + version: "0.1.2" category: lint source_keys: - context7-websites-vale-sh @@ -19,10 +14,8 @@ metadata: ## Gotchas -- Vale's exit code is driven by `error`-level alerts only. `warning` and `suggestion` alerts are reported but still exit `0`. `MinAlertLevel` and `--minAlertLevel` control display, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the single most common way a Vale gate silently passes everything. -- `vale ls-config` prints the fully-resolved, currently active configuration as JSON — the fastest way to check why a rule "isn't applying" is what's actually active, not what's written in `.vale.ini`. -- Inline suppression syntax is format-specific: Markdown uses HTML comments `<!-- vale off -->` / `<!-- vale on -->`, MDX uses `{/* vale off */}` / `{/* vale on */}`, Org mode uses `# vale off` / `# vale on`. The MDX form does nothing in a plain `.md` file — the alert still fires. Don't assume one syntax works across formats. -- Before calling the `vale` binary directly, check whether the target repo documents its own wrapper script for Vale (look in its README, CONTRIBUTING docs, pre-commit config, or a `scripts/` directory). Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter fields — and calling bare `vale` in a repo that has such a wrapper silently skips whatever the wrapper works around. If a wrapper is documented, invoke it with the same arguments instead of calling `vale` directly; otherwise fall back to the default below. +- Vale's exit code keys off `error`-level alerts only — `warning` and `suggestion` alerts print and still exit `0`, and `MinAlertLevel`/`--minAlertLevel` filter what is displayed, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the most common way a Vale gate silently passes everything. +- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. ## Running vale @@ -41,17 +34,19 @@ Key flags: | `--no-exit` | Suppresses the nonzero exit that `error`-level alerts would otherwise cause; a no-op when no rule is `error`-level. Use in CI stages that should surface lint output without hard-failing the build. | | `--ignore-syntax` | Treats input as plain text, skipping format-aware parsing — use when a file's syntax-aware parser produces noisy or wrong results. | -`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, that's a sign `vale sync` hasn't been run yet, not a `vale-run` problem. +`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` to confirm what actually loaded before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. Prefer `--output=JSON` whenever the caller (a script, a CI step, another agent) needs to act on individual alerts rather than just get a pass/fail signal — `CLI` and `line` are for humans reading the terminal. ## Fixing false positives +Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md` for your file's format: the markup is format-specific, and the wrong form suppresses nothing while Vale reports no error — the Markdown HTML-comment form is inert in an MDX file. + Scope the fix as narrowly as possible, in this order: 1. **Mentioning banned phrasing rather than using it**: wrap it in backticks or a fenced code block. Vale skips code spans and fences, so no suppression is needed at all. Try this before any suppression markup. 2. **One-off**: inline-suppress the specific text run with the format's `vale off`/`vale on` markup. -3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. +3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (in Markdown, e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. 4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression. Never disable a rule project-wide to fix one false positive — editing `.vale.ini`/`BasedOnStyles` is vale-config's job, and it silences the rule everywhere, not just the false-positive case. @@ -60,4 +55,4 @@ If output looks wrong because Vale mis-parsed a file's format, rerun with `--ign For CI that fails solely because Vale returned non-zero on `error`-level alerts — not because the content is wrong for that pipeline stage — add `--no-exit` rather than disabling the rule. If the failing alerts are warnings or suggestions, Vale is not what failed the build; look elsewhere. -If setting up Vale as a pre-commit hook or need the full inline-suppression/spelling-ignore syntax reference, read `references/troubleshooting.md`. +If a rule appears not to apply, if you need the spelling-ignore syntax, if a CI failure still needs diagnosing, or if setting Vale up as a pre-commit hook, read `references/troubleshooting.md`. diff --git a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md index 093280f..c7b601a 100644 --- a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md +++ b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md @@ -5,6 +5,13 @@ source_keys: # Vale troubleshooting reference +## Why a rule isn't applying + +`vale ls-config` prints the fully-resolved, currently active configuration as JSON. Check that +before rereading `.vale.ini` — what is written in the config file is not necessarily what is +active, and the resolved output is the fastest way to see which styles and rules a run actually +loaded. + ## Inline suppression syntax by format Markdown uses HTML comments — the MDX `{/* */}` form does not suppress anything in a plain `.md` file: diff --git a/plugins/lint/skills/vale-run/README.md b/plugins/lint/skills/vale-run/README.md index 8572456..459c68a 100644 --- a/plugins/lint/skills/vale-run/README.md +++ b/plugins/lint/skills/vale-run/README.md @@ -19,5 +19,5 @@ Describe what you want to lint and how (human-readable output, CI/JSON output, f | File | Purpose | |------|---------| | `SKILL.md` | Core invocation, key flags, output format guidance, false-positive triage order | -| `references/troubleshooting.md` | Inline suppression syntax, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | +| `references/troubleshooting.md` | Load when a rule appears not to apply, when writing inline suppression or spelling-ignore syntax, or when wiring Vale into pre-commit: resolved-config diagnostic (`vale ls-config`), format-specific suppression markup, rule-specific disabling, spelling ignore lists, pre-commit integration, CI edge cases | | `references/sources.md` | Research provenance | diff --git a/plugins/lint/skills/vale-run/SKILL.md b/plugins/lint/skills/vale-run/SKILL.md index 11f443a..2ceb78d 100644 --- a/plugins/lint/skills/vale-run/SKILL.md +++ b/plugins/lint/skills/vale-run/SKILL.md @@ -1,17 +1,12 @@ --- name: vale-run description: > - Use when running Vale (a prose/style linter) against files or directories in an - already-configured project — one that already has a .vale.ini — and interpreting - or reporting its results: choosing an output format for humans vs. CI, filtering - by severity, handling Vale's exit codes in scripts, or resolving common runtime - issues like false positives and unexpected CI failures. Use even if the user - doesn't say "vale" explicitly, e.g. "lint the docs", "check prose style", "run - the style linter", "why is CI failing on the docs check". Do not use when the - project has no .vale.ini yet, or needs styles installed/configured — that's the - vale-config skill. + Use when running Vale (a prose/style linter) on a project that already has a + .vale.ini and acting on its output — even when the user does not say "Vale", + as in "lint the docs", "check prose style", or "why is CI failing on the docs + check". Not setting up Vale config or styles -> `vale-config`. metadata: - version: "0.1.1" + version: "0.1.2" category: lint source_keys: - context7-websites-vale-sh @@ -19,10 +14,8 @@ metadata: ## Gotchas -- Vale's exit code is driven by `error`-level alerts only. `warning` and `suggestion` alerts are reported but still exit `0`. `MinAlertLevel` and `--minAlertLevel` control display, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the single most common way a Vale gate silently passes everything. -- `vale ls-config` prints the fully-resolved, currently active configuration as JSON — the fastest way to check why a rule "isn't applying" is what's actually active, not what's written in `.vale.ini`. -- Inline suppression syntax is format-specific: Markdown uses HTML comments `<!-- vale off -->` / `<!-- vale on -->`, MDX uses `{/* vale off */}` / `{/* vale on */}`, Org mode uses `# vale off` / `# vale on`. The MDX form does nothing in a plain `.md` file — the alert still fires. Don't assume one syntax works across formats. -- Before calling the `vale` binary directly, check whether the target repo documents its own wrapper script for Vale (look in its README, CONTRIBUTING docs, pre-commit config, or a `scripts/` directory). Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter fields — and calling bare `vale` in a repo that has such a wrapper silently skips whatever the wrapper works around. If a wrapper is documented, invoke it with the same arguments instead of calling `vale` directly; otherwise fall back to the default below. +- Vale's exit code keys off `error`-level alerts only — `warning` and `suggestion` alerts print and still exit `0`, and `MinAlertLevel`/`--minAlertLevel` filter what is displayed, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the most common way a Vale gate silently passes everything. +- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. ## Running vale @@ -41,17 +34,19 @@ Key flags: | `--no-exit` | Suppresses the nonzero exit that `error`-level alerts would otherwise cause; a no-op when no rule is `error`-level. Use in CI stages that should surface lint output without hard-failing the build. | | `--ignore-syntax` | Treats input as plain text, skipping format-aware parsing — use when a file's syntax-aware parser produces noisy or wrong results. | -`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, that's a sign `vale sync` hasn't been run yet, not a `vale-run` problem. +`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` to confirm what actually loaded before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. Prefer `--output=JSON` whenever the caller (a script, a CI step, another agent) needs to act on individual alerts rather than just get a pass/fail signal — `CLI` and `line` are for humans reading the terminal. ## Fixing false positives +Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md` for your file's format: the markup is format-specific, and the wrong form suppresses nothing while Vale reports no error — the Markdown HTML-comment form is inert in an MDX file. + Scope the fix as narrowly as possible, in this order: 1. **Mentioning banned phrasing rather than using it**: wrap it in backticks or a fenced code block. Vale skips code spans and fences, so no suppression is needed at all. Try this before any suppression markup. 2. **One-off**: inline-suppress the specific text run with the format's `vale off`/`vale on` markup. -3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. +3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (in Markdown, e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. 4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression. Never disable a rule project-wide to fix one false positive — editing `.vale.ini`/`BasedOnStyles` is vale-config's job, and it silences the rule everywhere, not just the false-positive case. @@ -60,4 +55,4 @@ If output looks wrong because Vale mis-parsed a file's format, rerun with `--ign For CI that fails solely because Vale returned non-zero on `error`-level alerts — not because the content is wrong for that pipeline stage — add `--no-exit` rather than disabling the rule. If the failing alerts are warnings or suggestions, Vale is not what failed the build; look elsewhere. -If setting up Vale as a pre-commit hook or need the full inline-suppression/spelling-ignore syntax reference, read `references/troubleshooting.md`. +If a rule appears not to apply, if you need the spelling-ignore syntax, if a CI failure still needs diagnosing, or if setting Vale up as a pre-commit hook, read `references/troubleshooting.md`. diff --git a/plugins/lint/skills/vale-run/references/troubleshooting.md b/plugins/lint/skills/vale-run/references/troubleshooting.md index 093280f..c7b601a 100644 --- a/plugins/lint/skills/vale-run/references/troubleshooting.md +++ b/plugins/lint/skills/vale-run/references/troubleshooting.md @@ -5,6 +5,13 @@ source_keys: # Vale troubleshooting reference +## Why a rule isn't applying + +`vale ls-config` prints the fully-resolved, currently active configuration as JSON. Check that +before rereading `.vale.ini` — what is written in the config file is not necessarily what is +active, and the resolved output is the fastest way to see which styles and rules a run actually +loaded. + ## Inline suppression syntax by format Markdown uses HTML comments — the MDX `{/* */}` form does not suppress anything in a plain `.md` file: -- 2.43.0 From 7e80c09b13a1d37c85f49e113f98f1743ca2beb0 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:07:16 +0000 Subject: [PATCH 33/89] fix(kyberforge): a dispatch table satisfies the reference-wiring rule body-discipline.md required every reference load to use the literal 'If X, read references/file.md' form and called anything else a generic pointer. ADR-0020's own cited dispatch exemplar, apm-workflow, uses a bare table plus one closing line, so an author could not satisfy both -- and the rule reliably produced duplication in exactly the bodies the contract exists to keep short. Resolves #109 with its option 1: a table row already pairs a condition with a target, so where a body dispatches, the table is the wiring. The literal form is what a body needs when it loads a reference without a table. Two corrections to the issue as filed. There is no Vale conflict -- PaddingPhrase.yml only matches 'see references/ for more info' and never fired on the exemplar, so this is a one-file prose fix and no rule changes. And gitea-workflow carried the predicted duplication: a three-row table restated underneath as three conditionals. Removed, body 227 -> 148 words. Closes #109 --- plugins/gitea/.apm/skills/gitea-workflow/SKILL.md | 6 +----- plugins/gitea/skills/gitea-workflow/SKILL.md | 6 +----- .../skill-audit/references/body-discipline.md | 15 ++++++++++++++- .../skill-audit/references/body-discipline.md | 15 ++++++++++++++- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md index eeee87b..c92c473 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md @@ -33,11 +33,7 @@ The invocation's shape selects exactly one branch. | A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | | A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | -If the invocation carries no specific request, read `references/status-checkin.md`. - -If the request references a bare number and never says "issue" or "PR", read `references/number-resolution.md`. - -If the request names a capability but not which skill owns it, read `references/skill-index.md`. +Read only the reference file matching the selected branch — each is self-contained for its concern. ## Report diff --git a/plugins/gitea/skills/gitea-workflow/SKILL.md b/plugins/gitea/skills/gitea-workflow/SKILL.md index eeee87b..c92c473 100644 --- a/plugins/gitea/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/skills/gitea-workflow/SKILL.md @@ -33,11 +33,7 @@ The invocation's shape selects exactly one branch. | A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | | A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | -If the invocation carries no specific request, read `references/status-checkin.md`. - -If the request references a bare number and never says "issue" or "PR", read `references/number-resolution.md`. - -If the request names a capability but not which skill owns it, read `references/skill-index.md`. +Read only the reference file matching the selected branch — each is self-contained for its concern. ## Report diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index 45430d2..0fa7971 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -31,7 +31,16 @@ Include content the agent lacks: Move to `references/`, behind an explicit "If X, read `references/<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 @@ -76,6 +85,10 @@ The reference shape in this repo is `apm-workflow`: a **421-word body** dispatch words — cite 421 when calibrating a body, or the conflation this section warns against reappears in the finding itself. +Note its wiring: a three-column table (invocation, action, reference file) closed by one line, +*"Read only the reference file matching the requested action."* That is the endorsed shape, and it +is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. + ## Gotchas sections The highest-value construct in a body, and the easiest to fill with noise. A Gotcha must state a diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index 45430d2..0fa7971 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -31,7 +31,16 @@ Include content the agent lacks: Move to `references/`, behind an explicit "If X, read `references/<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 @@ -76,6 +85,10 @@ The reference shape in this repo is `apm-workflow`: a **421-word body** dispatch words — cite 421 when calibrating a body, or the conflation this section warns against reappears in the finding itself. +Note its wiring: a three-column table (invocation, action, reference file) closed by one line, +*"Read only the reference file matching the requested action."* That is the endorsed shape, and it +is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. + ## Gotchas sections The highest-value construct in a body, and the easiest to fill with noise. A Gotcha must state a -- 2.43.0 From d8dfba958c3f39036514dedb76849a5129f1ca8c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:07:17 +0000 Subject: [PATCH 34/89] docs(agents): correct the ADR-0020 gate counts after wave 3 Three skills still exceed a FAIL tier, all in kyberforge, down from ten descriptions and two bodies. No routing target dangles any more, and the test suite now pins that set as empty rather than tracking a backlog. Refs #99 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5e2d009..6d25159 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline.** 10 of 39 descriptions and 2 of 39 bodies exceed their FAIL tier, and one routing target still dangles (`research` → `neuledge-context`). Editing any of those skills *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin. The `Kyberforge.CompositionNote` Vale rule currently fires nowhere, but `skill-size-check` does not cover the Vale half and the rule can be reintroduced by any new description, so check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline.** Three skills still exceed a FAIL tier, all in `kyberforge`: `apm-workflow` (817-char description), `forge` (648 chars, 1,093-word body) and `apm-install` (514 chars). Editing any of those three *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin and has `kyberforge` left. **No routing target dangles any more**, and `tests/test-adr0020-targets.sh` now pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. The `Kyberforge.CompositionNote` Vale rule fires nowhere, but `skill-size-check` does not cover the Vale half and any new description can reintroduce it, so check both: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. -- 2.43.0 From 915eb09ae2ed42d6faa3c7d2828a613ecf70e98a Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 15:51:43 +0000 Subject: [PATCH 35/89] docs(scripts): record that the bare-arrow carve-out is now unexercised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment justified the ADR-0020 compressed-form gate with diagnose's process chain 'fix -> regression-test', which without the gate read as a route to a non-existent regression-test skill. Issue #99 cut that chain when it retrofitted the description, so the gate now produces an identical verdict corpus-wide whether it is applied or not. Keeping the branch. It guards against prose no one has written yet, any new process chain re-arms it, and the bare-arrow rule it sits on is the sole extractor for three real targets in kyberforge's audit skills, all written unbackticked. Unexercised is not the same as unnecessary — the comment just needed to stop citing evidence that no longer exists. Refs #99 --- scripts/skill-size-check.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index e330810..8ce8c7c 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -456,8 +456,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 -- 2.43.0 From 1e4aab53a7733ae12da355731548935e0b0ad537 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:31:11 +0000 Subject: [PATCH 36/89] refactor(kyberforge): retrofit apm-workflow to the ADR-0020 contract Description 817 -> 324 chars, body 421 -> 237 words, Gotchas 6 -> 2. The five capability clauses, the second trigger register and the prose boundary form go; one trigger clause, the indirect trigger and one boundary clause remain. Four of six Gotchas move into the flow file that every branch needing them already loads. Two stay in the always-loaded body because a dispatch body must carry the gates common to every branch, not just the dispatch table: the MCP secret indirection rule, and the `apm experimental enable registries` precondition. The first pass moved registries into references/configure.md alone, which stranded it -- references/compile.md documents publishing to a registry and references/install.md resolves dependencies through one, and neither points at configure.md. Declaring a registry without the precondition is a silent no-op, so the failure had no signal. Also drops an unsourced claim the compression pass introduced (that apm checks `type:` going forward -- no source supports it), corrects the MCP rationale to install *or* runtime per configuration.md:98, and repoints two apm-orchestrate back-references that pointed at body Gotchas which had moved. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- .../.apm/agents/apm-orchestrate.agent.md | 4 +-- .../.apm/skills/apm-workflow/README.md | 8 +++--- .../.apm/skills/apm-workflow/SKILL.md | 27 ++++++------------- .../skills/apm-workflow/references/audit.md | 4 +++ .../skills/apm-workflow/references/compile.md | 2 +- .../apm-workflow/references/configure.md | 10 ++++--- .../apm-workflow/references/marketplace.md | 1 + .../agents/apm-orchestrate.agent.md | 4 +-- .../kyberforge/skills/apm-workflow/README.md | 8 +++--- .../kyberforge/skills/apm-workflow/SKILL.md | 27 ++++++------------- .../skills/apm-workflow/references/audit.md | 4 +++ .../skills/apm-workflow/references/compile.md | 2 +- .../apm-workflow/references/configure.md | 10 ++++--- .../apm-workflow/references/marketplace.md | 1 + 14 files changed, 52 insertions(+), 60 deletions(-) diff --git a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md index 3d135ad..0e048c9 100644 --- a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md @@ -19,10 +19,10 @@ 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. +- 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. - `apm.yml`'s `type:` field constrains what `.apm/` may contain — when scaffolding (`init-package`), set `type:` before any primitive content is added; do not defer it. - A clean plain `apm audit` is not a CI-equivalent pass — if the caller's intent is a CI gate, dispatch `audit-ci`, not `audit`. -- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/SKILL.md Gotchas for the underlying constraint. +- 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: diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/README.md b/plugins/kyberforge/.apm/skills/apm-workflow/README.md index 1996384..6d1e5a7 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/README.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/README.md @@ -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 two gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition) | +| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, `includes:`, registries; `type:` and `experimental enable registries` traps | | `references/install.md` | apm install, apm install [PACKAGE_REF], --update, --target agent-skills | -| `references/marketplace.md` | Building/registering a marketplace, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | +| `references/marketplace.md` | Building/registering a marketplace, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | | `references/compile.md` | apm compile / pack / publish / run, claude plugin validate agents/ gotcha | -| `references/audit.md` | apm audit, apm audit --ci, apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | +| `references/audit.md` | apm audit vs apm audit --ci (they check different things), apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | | `references/sources.md` | Provenance chain — research sources that informed this skill | diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md index 15270fe..fa86cae 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md @@ -1,18 +1,11 @@ --- 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 the user wants to author, scaffold, install, compile, publish, or + audit an apm package, an apm.yml manifest, or an apm marketplace, or register + someone else's to consume — even when they do not say "apm" + explicitly, e.g. "set up the package manifest". Not the apm binary itself or + an agent runtime -> `apm-install`. metadata: category: apm source_keys: @@ -21,12 +14,8 @@ 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. ## Step 1 — Dispatch @@ -38,7 +27,7 @@ metadata: | `/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` | -Read only the reference file matching the requested action — each is self-contained for its concern. +Read only the reference file matching the requested action — each is self-contained for its concern, and each carries the traps specific to its flow. ## Step 2 — Execute diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md index 4e2b2c4..48eeda4 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md @@ -53,3 +53,7 @@ jobs: ## Claude Code validation is a separate terminal check For packages compiling to the `claude` target, also run `claude plugin validate [--strict]` against the compiled output as a terminal check. `apm audit`/`apm audit --ci` do not check Claude Code-marketplace-specific schema rules (reserved prefixes, the `agents/` stray-file behavior — see `references/compile.md` — etc.), so they don't substitute for it. + +## Gotchas + +- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md index d8c5b5b..d9b77d9 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md @@ -40,7 +40,7 @@ Bundles a producer package into a distributable artifact. Default to `--dry-run `includes: auto` does NOT sweep generic root-level passthrough files (README.md, docs/, sources.md, config files, etc.) into the `apm pack` distribution bundle (`build/<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 diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md index 191104d..e99cc0e 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md @@ -25,9 +25,9 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains `.apm/` contents +- `type` — `instructions | skill | hybrid | prompts`; constrains what `.apm/` may contain, so set it before scaffolding content (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` @@ -68,14 +68,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 +85,6 @@ apm config set registry.corp-main.default true ## Gotchas +- `apm.yml`'s `type:` field constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later does not retroactively validate what is already on disk. +- `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. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md index 096bc99..68fa29f 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md @@ -104,6 +104,7 @@ hook. ## Gotchas +- `apm marketplace add` and `apm marketplace package add` point in opposite directions and are easy to conflate: `add` registers someone else's catalog for you to *consume*; `package add` registers a package by remote reference (`owner/repo`, host URL, or full URL) into a marketplace you are *building*. `package add` does not accept a local path — register a local package by hand-editing `marketplace.packages[]`, as above. - Only `claude` and `codex` marketplace output profiles exist in apm 0.28.0 — confirmed via source (`apm_cli/marketplace/output_profiles.py`: `MARKETPLACE_OUTPUTS = {claude: ..., codex: ...}`, nothing else). There is no native Copilot marketplace output; `apm pack` will not generate one. A repo needing a Copilot-consumable marketplace manifest must maintain it separately by other means. - A package/plugin `name` compiling to the `claude` target must avoid Claude Code's reserved name prefixes/values — `anthropic-*`, `claude-*`, `agent-skills`, `official-claude-plugins` — otherwise the compiled `.claude-plugin/marketplace.json` is rejected by Claude Code's validator. This is a Claude Code platform constraint, independent of how the manifest gets authored. - Removing a package from `marketplace.packages[]` in `apm.yml` and re-packing changes the public/consumed catalog. Confirm with the user in conversation before removing the entry and running `apm pack` — this is a normal `apm.yml` edit (not a distinct apm-orchestrate operation with its own gate), so the confirmation is conversational, the same as any other consequential manifest edit. diff --git a/plugins/kyberforge/agents/apm-orchestrate.agent.md b/plugins/kyberforge/agents/apm-orchestrate.agent.md index 3d135ad..0e048c9 100644 --- a/plugins/kyberforge/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/agents/apm-orchestrate.agent.md @@ -19,10 +19,10 @@ 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. +- 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. - `apm.yml`'s `type:` field constrains what `.apm/` may contain — when scaffolding (`init-package`), set `type:` before any primitive content is added; do not defer it. - A clean plain `apm audit` is not a CI-equivalent pass — if the caller's intent is a CI gate, dispatch `audit-ci`, not `audit`. -- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/SKILL.md Gotchas for the underlying constraint. +- 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: diff --git a/plugins/kyberforge/skills/apm-workflow/README.md b/plugins/kyberforge/skills/apm-workflow/README.md index 1996384..6d1e5a7 100644 --- a/plugins/kyberforge/skills/apm-workflow/README.md +++ b/plugins/kyberforge/skills/apm-workflow/README.md @@ -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 two gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition) | +| `references/configure.md` | apm.yml schema, apm plugin init, dependency forms, MCP secrets, `includes:`, registries; `type:` and `experimental enable registries` traps | | `references/install.md` | apm install, apm install [PACKAGE_REF], --update, --target agent-skills | -| `references/marketplace.md` | Building/registering a marketplace, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | +| `references/marketplace.md` | Building/registering a marketplace, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | | `references/compile.md` | apm compile / pack / publish / run, claude plugin validate agents/ gotcha | -| `references/audit.md` | apm audit, apm audit --ci, apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | +| `references/audit.md` | apm audit vs apm audit --ci (they check different things), apm marketplace check, CI wiring, frozen installs, claude plugin validate terminal check | | `references/sources.md` | Provenance chain — research sources that informed this skill | diff --git a/plugins/kyberforge/skills/apm-workflow/SKILL.md b/plugins/kyberforge/skills/apm-workflow/SKILL.md index 15270fe..fa86cae 100644 --- a/plugins/kyberforge/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/skills/apm-workflow/SKILL.md @@ -1,18 +1,11 @@ --- 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 the user wants to author, scaffold, install, compile, publish, or + audit an apm package, an apm.yml manifest, or an apm marketplace, or register + someone else's to consume — even when they do not say "apm" + explicitly, e.g. "set up the package manifest". Not the apm binary itself or + an agent runtime -> `apm-install`. metadata: category: apm source_keys: @@ -21,12 +14,8 @@ 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. ## Step 1 — Dispatch @@ -38,7 +27,7 @@ metadata: | `/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` | -Read only the reference file matching the requested action — each is self-contained for its concern. +Read only the reference file matching the requested action — each is self-contained for its concern, and each carries the traps specific to its flow. ## Step 2 — Execute diff --git a/plugins/kyberforge/skills/apm-workflow/references/audit.md b/plugins/kyberforge/skills/apm-workflow/references/audit.md index 4e2b2c4..48eeda4 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/audit.md +++ b/plugins/kyberforge/skills/apm-workflow/references/audit.md @@ -53,3 +53,7 @@ jobs: ## Claude Code validation is a separate terminal check For packages compiling to the `claude` target, also run `claude plugin validate [--strict]` against the compiled output as a terminal check. `apm audit`/`apm audit --ci` do not check Claude Code-marketplace-specific schema rules (reserved prefixes, the `agents/` stray-file behavior — see `references/compile.md` — etc.), so they don't substitute for it. + +## Gotchas + +- Plain `apm audit` and `apm audit --ci` check different things: plain `apm audit` scans deployed files for hidden Unicode only; `--ci` additionally runs lockfile-consistency checks, install-replay drift detection, and org policy checks. A clean plain `apm audit` is not a CI-equivalent pass. diff --git a/plugins/kyberforge/skills/apm-workflow/references/compile.md b/plugins/kyberforge/skills/apm-workflow/references/compile.md index d8c5b5b..d9b77d9 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/skills/apm-workflow/references/compile.md @@ -40,7 +40,7 @@ Bundles a producer package into a distributable artifact. Default to `--dry-run `includes: auto` does NOT sweep generic root-level passthrough files (README.md, docs/, sources.md, config files, etc.) into the `apm pack` distribution bundle (`build/<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 diff --git a/plugins/kyberforge/skills/apm-workflow/references/configure.md b/plugins/kyberforge/skills/apm-workflow/references/configure.md index 191104d..e99cc0e 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/skills/apm-workflow/references/configure.md @@ -25,9 +25,9 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains `.apm/` contents +- `type` — `instructions | skill | hybrid | prompts`; constrains what `.apm/` may contain, so set it before scaffolding content (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` @@ -68,14 +68,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 +85,6 @@ apm config set registry.corp-main.default true ## Gotchas +- `apm.yml`'s `type:` field constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later does not retroactively validate what is already on disk. +- `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. diff --git a/plugins/kyberforge/skills/apm-workflow/references/marketplace.md b/plugins/kyberforge/skills/apm-workflow/references/marketplace.md index 096bc99..68fa29f 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/marketplace.md +++ b/plugins/kyberforge/skills/apm-workflow/references/marketplace.md @@ -104,6 +104,7 @@ hook. ## Gotchas +- `apm marketplace add` and `apm marketplace package add` point in opposite directions and are easy to conflate: `add` registers someone else's catalog for you to *consume*; `package add` registers a package by remote reference (`owner/repo`, host URL, or full URL) into a marketplace you are *building*. `package add` does not accept a local path — register a local package by hand-editing `marketplace.packages[]`, as above. - Only `claude` and `codex` marketplace output profiles exist in apm 0.28.0 — confirmed via source (`apm_cli/marketplace/output_profiles.py`: `MARKETPLACE_OUTPUTS = {claude: ..., codex: ...}`, nothing else). There is no native Copilot marketplace output; `apm pack` will not generate one. A repo needing a Copilot-consumable marketplace manifest must maintain it separately by other means. - A package/plugin `name` compiling to the `claude` target must avoid Claude Code's reserved name prefixes/values — `anthropic-*`, `claude-*`, `agent-skills`, `official-claude-plugins` — otherwise the compiled `.claude-plugin/marketplace.json` is rejected by Claude Code's validator. This is a Claude Code platform constraint, independent of how the manifest gets authored. - Removing a package from `marketplace.packages[]` in `apm.yml` and re-packing changes the public/consumed catalog. Confirm with the user in conversation before removing the entry and running `apm pack` — this is a normal `apm.yml` edit (not a distinct apm-orchestrate operation with its own gate), so the confirmation is conversational, the same as any other consequential manifest edit. -- 2.43.0 From 4aab9d327c4457bed53b24144483473aacd068da Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:31:23 +0000 Subject: [PATCH 37/89] refactor(kyberforge): retrofit forge to the ADR-0020 contract Description 648 -> 387 chars, body 1093 -> 541 words. This was the last body FAIL in the 39-skill corpus. The body was not trimmed to fit. forge routes four artifact types that a single invocation classifies between, so the contract requires a dispatch table plus the gates common to every route, with each route self-contained in references/. Adds references/author-routes.md (skill and agent), references/apm-routes.md (plugin and marketplace entry) and references/version-bump.md. Skill and agent share one file: they differ on one axis only, which audit skill verifies the result. Fixes three defects the first pass introduced or relocated: - references/apm-routes.md claimed `apm audit` "already runs inside apm-workflow's own flow" and told the agent to confirm it ran clean. apm-workflow dispatches audit as its own row; the configure and marketplace rows never reach it. That was the only completion check these routes had, and it could never be satisfied. Replaced with a manual read-back the agent performs itself. - "Read only the reference file" forbade the multi-artifact case the same body documents two lines later, and ADR-0011 records eight artifacts authored in one pass. - The announce gate became a closing gate, reachable only after the invocation it was meant to precede. Moved to the end of Step 2. Also restores the artifact enumeration to the plugin row, normalises to bare unnamespaced skill names per AGENTS.md, adds a dispatch fallback for artifacts matching no row, and corrects three provenance entries -- one asserted a contribution that did not happen. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- .../kyberforge/.apm/skills/forge/README.md | 23 +++--- plugins/kyberforge/.apm/skills/forge/SKILL.md | 81 ++++++------------- .../skills/forge/references/apm-routes.md | 42 ++++++++++ .../skills/forge/references/author-routes.md | 44 ++++++++++ .../.apm/skills/forge/references/sources.md | 8 +- .../skills/forge/references/version-bump.md | 37 +++++++++ plugins/kyberforge/skills/forge/README.md | 23 +++--- plugins/kyberforge/skills/forge/SKILL.md | 81 ++++++------------- .../skills/forge/references/apm-routes.md | 42 ++++++++++ .../skills/forge/references/author-routes.md | 44 ++++++++++ .../skills/forge/references/sources.md | 8 +- .../skills/forge/references/version-bump.md | 37 +++++++++ 12 files changed, 330 insertions(+), 140 deletions(-) create mode 100644 plugins/kyberforge/.apm/skills/forge/references/apm-routes.md create mode 100644 plugins/kyberforge/.apm/skills/forge/references/author-routes.md create mode 100644 plugins/kyberforge/.apm/skills/forge/references/version-bump.md create mode 100644 plugins/kyberforge/skills/forge/references/apm-routes.md create mode 100644 plugins/kyberforge/skills/forge/references/author-routes.md create mode 100644 plugins/kyberforge/skills/forge/references/version-bump.md diff --git a/plugins/kyberforge/.apm/skills/forge/README.md b/plugins/kyberforge/.apm/skills/forge/README.md index b8f4857..aaa8bb8 100644 --- a/plugins/kyberforge/.apm/skills/forge/README.md +++ b/plugins/kyberforge/.apm/skills/forge/README.md @@ -1,12 +1,12 @@ # forge -Guided entry point for building or improving something in kyberforge when the target artifact type isn't decided yet. +Guided entry point for building or improving something in any plugin of this repo when the target artifact type isn't decided yet. ## What it does Grills the user's intent via `bin:grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. -Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and their real terminal check (`apm audit`) is already part of their own flow. +Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and no automatic terminal check either — `apm audit` is a separate `apm-workflow` action, not a closing step of the configure or marketplace flow — so forge verifies those routes by reading the written manifest back against the grilled intent. ## Before you start @@ -22,16 +22,19 @@ Skip forge and call the target skill directly (`/skill-author`, `/agent-author`, ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/sources.md` | Provenance chain — research sources that informed this skill | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — Gotchas, the grill step, the classification dispatch table, and the gates common to every route | +| `references/author-routes.md` | The intent classifies as a skill or an agent/subagent definition — fork-vs-inline judgment and the two-tier verification loop | +| `references/apm-routes.md` | The intent classifies as a plugin or a marketplace entry — always-inline invocation, why these routes get no clean-context recheck, and the manual read-back that stands in for one | +| `references/version-bump.md` | A finished route left the owning package's version unbumped — walk-up rule and the clean-context bump brief | +| `references/sources.md` | Never loaded at runtime — provenance chain for the research sources that informed this skill | ## Routes to | Artifact type | Skill | |---|---| -| Skill | `kyberforge:skill-author` | -| Agent / subagent definition | `kyberforge:agent-author` | -| Plugin | `kyberforge:apm-workflow` (configure) | -| Marketplace entry | `kyberforge:apm-workflow` (marketplace) | +| Skill | `skill-author` | +| Agent / subagent definition | `agent-author` | +| Plugin | `apm-workflow` (configure) | +| Marketplace entry | `apm-workflow` (marketplace) | diff --git a/plugins/kyberforge/.apm/skills/forge/SKILL.md b/plugins/kyberforge/.apm/skills/forge/SKILL.md index b0318e0..99de5bd 100644 --- a/plugins/kyberforge/.apm/skills/forge/SKILL.md +++ b/plugins/kyberforge/.apm/skills/forge/SKILL.md @@ -1,16 +1,12 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something - but hasn't yet named which of it (skill, agent, plugin, or marketplace - entry) they need — "I want to add something to kyberforge", "not sure if - this should be a skill or a plugin", "help me figure out what to build", - "I have an idea but don't know where it belongs". Grills the intent first, - classifies the target artifact type, then routes to the matching author - skill. Do not use when the user already names the target artifact type or - skill/agent explicitly (e.g. "run /skill-author on my-skill", "create an - agent for X") — route directly to that author skill instead, bypassing - forge. + Use when the user wants to build, add, or improve something but has not yet + named the artifact type — skill, agent, plugin, or marketplace entry; "a + skill for the gitea plugin, or an agent?". Grills the intent, classifies the + artifact, then 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,35 @@ 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. Every routing branch below turns on that distinction. ## 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 `bin:grill-with-docs` unless a grill session has already run and is available in the context. -## Step 2 — Classify the artifact type +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. -Match the grilled intent against exactly one row (or more than one, if the intent genuinely spans several): +## Step 2 — Classify and dispatch -| 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`) | +Match the grilled intent against exactly one row — or more than one, if the intent genuinely spans several artifacts. -If the intent is genuinely ambiguous between rows even after grilling, ask the user directly rather than guessing. +| 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` | -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. +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. -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. +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. -## Step 3 — Announce, then route +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. -State the classification and which skill(s) will run before invoking anything. +**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. -**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. +## Step 3 — Closing gates, common to every route -`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). - -**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.** If the finished route's completion message carries no evidence of a package version bump, read `references/version-bump.md`. diff --git a/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md b/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md new file mode 100644 index 0000000..3e04823 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/apm-routes.md @@ -0,0 +1,42 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a plugin or marketplace entry to apm-workflow + +Reached from `SKILL.md` Step 2 when the classified artifact is a plugin or a marketplace entry. +Both route to `apm-workflow` — a plugin to its configure flow (`apm plugin init`), a marketplace +entry to its marketplace flow (`apm marketplace package add`). + +No other skill is a candidate for these two rows: `plugin-author` and `marketplace-author` were +removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole successor. + +## Always inline, never forked + +Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or +gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace +entry takes a conversational confirmation — and a backgrounded fork cannot surface those +checkpoints to the user in real time. + +## No clean-context recheck, and no automatic audit + +Skill and agent routes close with a clean-context audit rerun; these two do not, and the omission +is deliberate rather than an oversight. Neither artifact type has an audit skill counterpart to +re-run, so detaching the route to earn a recheck it would never get buys nothing. + +These routes get no automated terminal check either. `apm audit` is a separate action on +`apm-workflow`'s own dispatch table, not a closing step of the configure or marketplace flow a +forge route lands in, so a completion message from either says nothing about it. Do not wait for +one and do not report one you did not see. + +Verify by hand instead. Read back what the route wrote against what the grill settled: + +- **Plugin** — the package directory exists where the intent said it should, and its `apm.yml` + carries the intended `name`, a top-level `type:` field, and a `version`. +- **Marketplace entry** — the entry names that package, points at the source the intent settled + on, and carries the version the package actually declares. + +If the change warrants the full integrity and policy check rather than a read-back, invoke +`apm-workflow` again for its audit action and run `apm audit` deliberately. Then return to +`SKILL.md` Step 3 for the closing gates common to every route. diff --git a/plugins/kyberforge/.apm/skills/forge/references/author-routes.md b/plugins/kyberforge/.apm/skills/forge/references/author-routes.md new file mode 100644 index 0000000..237debc --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/author-routes.md @@ -0,0 +1,44 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a skill or agent to its author skill + +Reached from `SKILL.md` Step 2 when the classified artifact is a skill or an agent/subagent +definition. Route a skill to `skill-author` and an agent to `agent-author`. The two branches +differ on one axis only — which audit skill verifies the result — and everything below applies to +both. + +## Choose fork or inline + +Default to a **fork subagent**. It inherits the full grilled-intent conversation, so the author +skill does not need re-briefing on what the user asked for or what the grill settled. + +Fall back to an **inline invocation** — same conversation, no subagent — when either holds: + +- **Fork is technically unavailable.** You are already running inside a fork (a fork cannot spawn + another fork), a nesting-depth cap is reached, or the environment does not support forking. +- **The routed flow needs live user interaction mid-run** that a backgrounded fork cannot surface + in real time: clarifying questions, confirmation checkpoints, or a HITL gate. Judge this from + context — if nothing about the flow signals a live checkpoint, prefer the fork. + +## Two-tier verification + +Both author skills already close out with their own inline audit, in the same context as the +authoring work: `skill-author` runs `/skill-audit`, `agent-author` invokes +`agent-audit`. That is tier one, and forge does not change it. + +Tier two belongs to forge. Once the author skill's run has finished, spin up a separate +**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run the +same audit skill against the finished artifact. This is a distinct verification layer, not a +duplicate: the inline audit shares context with the work it is checking and can share its blind +spots, while the clean rerun has no stake in the result. + +If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, +any actionable finding on its own — loop: re-invoke the author skill (same fork-versus-inline +judgment as the first invocation) to resolve it, then re-run the clean audit. Repeat until the +clean audit comes back with nothing unresolved. Only then is the route done. This is the same +resolve-before-close discipline the author skills already apply to their own inline audit. + +Return to `SKILL.md` Step 3 for the closing gates common to every route once the loop closes. diff --git a/plugins/kyberforge/.apm/skills/forge/references/sources.md b/plugins/kyberforge/.apm/skills/forge/references/sources.md index 5d9aac7..4f065b3 100644 --- a/plugins/kyberforge/.apm/skills/forge/references/sources.md +++ b/plugins/kyberforge/.apm/skills/forge/references/sources.md @@ -4,15 +4,15 @@ - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md -- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds Step 3's fork-vs-inline invocation logic: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. -- **Contributing files:** SKILL.md +- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds the fork-vs-inline invocation logic in `references/author-routes.md`, the always-inline decision for the apm routes in `references/apm-routes.md`, and the clean-context bump subagent in `references/version-bump.md`: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. +- **Contributing files:** SKILL.md, references/author-routes.md, references/apm-routes.md, references/version-bump.md - **Status:** `extracted` ## context7-websites-code-claude - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md -- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry warning against conflating the two. +- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry in `SKILL.md` warning against conflating the two; nothing else in this skill draws on it, and no `references/` file mentions the `context: fork` field. - **Contributing files:** SKILL.md - **Status:** `extracted` @@ -28,7 +28,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md -- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category. forge has none of that bulk, so a lean SKILL.md-plus-provenance-file shape is spec-legitimate; the `references/sources.md` in this directory exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. +- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: ADR-0020's rule that dispatch is mandatory at two or more mutually exclusive flows, which forge's four-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. - **Contributing files:** SKILL.md - **Status:** `extracted` diff --git a/plugins/kyberforge/.apm/skills/forge/references/version-bump.md b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md new file mode 100644 index 0000000..8a9662a --- /dev/null +++ b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md @@ -0,0 +1,37 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Bumping the package version after a route + +Reached from `SKILL.md` Step 3 when a route has finished and its completion message carries no +evidence that the package version was bumped. The author skills bump it themselves in some flows, +so check their output before doing anything here — a second bump for one artifact 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 X.Y.Z to X.Y.Z to reflect the new +`<artifact-name>`." diff --git a/plugins/kyberforge/skills/forge/README.md b/plugins/kyberforge/skills/forge/README.md index b8f4857..aaa8bb8 100644 --- a/plugins/kyberforge/skills/forge/README.md +++ b/plugins/kyberforge/skills/forge/README.md @@ -1,12 +1,12 @@ # forge -Guided entry point for building or improving something in kyberforge when the target artifact type isn't decided yet. +Guided entry point for building or improving something in any plugin of this repo when the target artifact type isn't decided yet. ## What it does Grills the user's intent via `bin:grill-with-docs` (inline, interactive) against this repo's `CONTEXT.md` and `docs/adr/`, classifies the target artifact type (skill, agent/subagent definition, plugin, or marketplace entry), announces the classification, then routes to the matching author skill — chaining more than one, in dependency order, if the intent spans multiple artifact types. -Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and their real terminal check (`apm audit`) is already part of their own flow. +Author-skill invocation defaults to a fork subagent (inherits the grilled-intent context) and falls back to inline when forking isn't possible or the routed flow needs live user interaction (clarifying questions, a HITL gate). After a `skill-author` or `agent-author` route finishes — each already closes out with its own inline audit — forge spins up a separate clean-context subagent to independently re-run the matching audit skill (`skill-audit` / `agent-audit`) as a distinct check on the finished artifact, not a duplicate of the inline one. If that clean audit turns up any unresolved finding, forge loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved. `apm-workflow` routes (plugin, marketplace entry) get no recheck: they have no audit counterpart, and no automatic terminal check either — `apm audit` is a separate `apm-workflow` action, not a closing step of the configure or marketplace flow — so forge verifies those routes by reading the written manifest back against the grilled intent. ## Before you start @@ -22,16 +22,19 @@ Skip forge and call the target skill directly (`/skill-author`, `/agent-author`, ## Files -| File | Purpose | -|------|---------| -| `SKILL.md` | Skill instructions for agents | -| `references/sources.md` | Provenance chain — research sources that informed this skill | +| File | Loaded when | +|------|-------------| +| `SKILL.md` | Always — Gotchas, the grill step, the classification dispatch table, and the gates common to every route | +| `references/author-routes.md` | The intent classifies as a skill or an agent/subagent definition — fork-vs-inline judgment and the two-tier verification loop | +| `references/apm-routes.md` | The intent classifies as a plugin or a marketplace entry — always-inline invocation, why these routes get no clean-context recheck, and the manual read-back that stands in for one | +| `references/version-bump.md` | A finished route left the owning package's version unbumped — walk-up rule and the clean-context bump brief | +| `references/sources.md` | Never loaded at runtime — provenance chain for the research sources that informed this skill | ## Routes to | Artifact type | Skill | |---|---| -| Skill | `kyberforge:skill-author` | -| Agent / subagent definition | `kyberforge:agent-author` | -| Plugin | `kyberforge:apm-workflow` (configure) | -| Marketplace entry | `kyberforge:apm-workflow` (marketplace) | +| Skill | `skill-author` | +| Agent / subagent definition | `agent-author` | +| Plugin | `apm-workflow` (configure) | +| Marketplace entry | `apm-workflow` (marketplace) | diff --git a/plugins/kyberforge/skills/forge/SKILL.md b/plugins/kyberforge/skills/forge/SKILL.md index b0318e0..99de5bd 100644 --- a/plugins/kyberforge/skills/forge/SKILL.md +++ b/plugins/kyberforge/skills/forge/SKILL.md @@ -1,16 +1,12 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something - but hasn't yet named which of it (skill, agent, plugin, or marketplace - entry) they need — "I want to add something to kyberforge", "not sure if - this should be a skill or a plugin", "help me figure out what to build", - "I have an idea but don't know where it belongs". Grills the intent first, - classifies the target artifact type, then routes to the matching author - skill. Do not use when the user already names the target artifact type or - skill/agent explicitly (e.g. "run /skill-author on my-skill", "create an - agent for X") — route directly to that author skill instead, bypassing - forge. + Use when the user wants to build, add, or improve something but has not yet + named the artifact type — skill, agent, plugin, or marketplace entry; "a + skill for the gitea plugin, or an agent?". Grills the intent, classifies the + artifact, then 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,35 @@ 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. Every routing branch below turns on that distinction. ## 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 `bin:grill-with-docs` unless a grill session has already run and is available in the context. -## Step 2 — Classify the artifact type +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. -Match the grilled intent against exactly one row (or more than one, if the intent genuinely spans several): +## Step 2 — Classify and dispatch -| 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`) | +Match the grilled intent against exactly one row — or more than one, if the intent genuinely spans several artifacts. -If the intent is genuinely ambiguous between rows even after grilling, ask the user directly rather than guessing. +| 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` | -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. +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. -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. +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. -## Step 3 — Announce, then route +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. -State the classification and which skill(s) will run before invoking anything. +**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. -**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. +## Step 3 — Closing gates, common to every route -`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). - -**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.** If the finished route's completion message carries no evidence of a package version bump, read `references/version-bump.md`. diff --git a/plugins/kyberforge/skills/forge/references/apm-routes.md b/plugins/kyberforge/skills/forge/references/apm-routes.md new file mode 100644 index 0000000..3e04823 --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/apm-routes.md @@ -0,0 +1,42 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a plugin or marketplace entry to apm-workflow + +Reached from `SKILL.md` Step 2 when the classified artifact is a plugin or a marketplace entry. +Both route to `apm-workflow` — a plugin to its configure flow (`apm plugin init`), a marketplace +entry to its marketplace flow (`apm marketplace package add`). + +No other skill is a candidate for these two rows: `plugin-author` and `marketplace-author` were +removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole successor. + +## Always inline, never forked + +Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or +gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace +entry takes a conversational confirmation — and a backgrounded fork cannot surface those +checkpoints to the user in real time. + +## No clean-context recheck, and no automatic audit + +Skill and agent routes close with a clean-context audit rerun; these two do not, and the omission +is deliberate rather than an oversight. Neither artifact type has an audit skill counterpart to +re-run, so detaching the route to earn a recheck it would never get buys nothing. + +These routes get no automated terminal check either. `apm audit` is a separate action on +`apm-workflow`'s own dispatch table, not a closing step of the configure or marketplace flow a +forge route lands in, so a completion message from either says nothing about it. Do not wait for +one and do not report one you did not see. + +Verify by hand instead. Read back what the route wrote against what the grill settled: + +- **Plugin** — the package directory exists where the intent said it should, and its `apm.yml` + carries the intended `name`, a top-level `type:` field, and a `version`. +- **Marketplace entry** — the entry names that package, points at the source the intent settled + on, and carries the version the package actually declares. + +If the change warrants the full integrity and policy check rather than a read-back, invoke +`apm-workflow` again for its audit action and run `apm audit` deliberately. Then return to +`SKILL.md` Step 3 for the closing gates common to every route. diff --git a/plugins/kyberforge/skills/forge/references/author-routes.md b/plugins/kyberforge/skills/forge/references/author-routes.md new file mode 100644 index 0000000..237debc --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/author-routes.md @@ -0,0 +1,44 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Routing a skill or agent to its author skill + +Reached from `SKILL.md` Step 2 when the classified artifact is a skill or an agent/subagent +definition. Route a skill to `skill-author` and an agent to `agent-author`. The two branches +differ on one axis only — which audit skill verifies the result — and everything below applies to +both. + +## Choose fork or inline + +Default to a **fork subagent**. It inherits the full grilled-intent conversation, so the author +skill does not need re-briefing on what the user asked for or what the grill settled. + +Fall back to an **inline invocation** — same conversation, no subagent — when either holds: + +- **Fork is technically unavailable.** You are already running inside a fork (a fork cannot spawn + another fork), a nesting-depth cap is reached, or the environment does not support forking. +- **The routed flow needs live user interaction mid-run** that a backgrounded fork cannot surface + in real time: clarifying questions, confirmation checkpoints, or a HITL gate. Judge this from + context — if nothing about the flow signals a live checkpoint, prefer the fork. + +## Two-tier verification + +Both author skills already close out with their own inline audit, in the same context as the +authoring work: `skill-author` runs `/skill-audit`, `agent-author` invokes +`agent-audit`. That is tier one, and forge does not change it. + +Tier two belongs to forge. Once the author skill's run has finished, spin up a separate +**clean-context subagent** — fresh, not forked, no inherited context — to independently re-run the +same audit skill against the finished artifact. This is a distinct verification layer, not a +duplicate: the inline audit shares context with the work it is checking and can share its blind +spots, while the clean rerun has no stake in the result. + +If the clean audit surfaces any unresolved finding — not only a disagreement with the inline pass, +any actionable finding on its own — loop: re-invoke the author skill (same fork-versus-inline +judgment as the first invocation) to resolve it, then re-run the clean audit. Repeat until the +clean audit comes back with nothing unresolved. Only then is the route done. This is the same +resolve-before-close discipline the author skills already apply to their own inline audit. + +Return to `SKILL.md` Step 3 for the closing gates common to every route once the loop closes. diff --git a/plugins/kyberforge/skills/forge/references/sources.md b/plugins/kyberforge/skills/forge/references/sources.md index 5d9aac7..4f065b3 100644 --- a/plugins/kyberforge/skills/forge/references/sources.md +++ b/plugins/kyberforge/skills/forge/references/sources.md @@ -4,15 +4,15 @@ - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md -- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds Step 3's fork-vs-inline invocation logic: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. -- **Contributing files:** SKILL.md +- **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations. Grounds the fork-vs-inline invocation logic in `references/author-routes.md`, the always-inline decision for the apm routes in `references/apm-routes.md`, and the clean-context bump subagent in `references/version-bump.md`: fork inherits full conversation history via `/fork` or `subagent_type: "fork"`, is not a declarable frontmatter field on any agent definition, cannot be nested (a fork cannot spawn another fork), and is a caller-side invocation choice rather than a property of the artifact being routed to. +- **Contributing files:** SKILL.md, references/author-routes.md, references/apm-routes.md, references/version-bump.md - **Status:** `extracted` ## context7-websites-code-claude - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md -- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry warning against conflating the two. +- **Description:** Official Claude Code documentation site indexed by Context7 — confirms the `context: fork` skill-level frontmatter field means isolated/fresh execution, the opposite of what the `/fork` subagent command does (inherits conversation). Informs the Gotchas entry in `SKILL.md` warning against conflating the two; nothing else in this skill draws on it, and no `references/` file mentions the `context: fork` field. - **Contributing files:** SKILL.md - **Status:** `extracted` @@ -28,7 +28,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md -- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category. forge has none of that bulk, so a lean SKILL.md-plus-provenance-file shape is spec-legitimate; the `references/sources.md` in this directory exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. +- **Description:** Complete SKILL.md format specification — confirms `assets/`, `references/`, and `scripts/` are warranted only by the bulk/reusability of supporting content (large reference material, executable code, templates), not by a skill's category, and forge's per-route procedures are that kind of supporting content — so the spec permits the `references/` split here but does not require it. The warrant is a house decision: ADR-0020's rule that dispatch is mandatory at two or more mutually exclusive flows, which forge's four-row table is. `references/sources.md` likewise exists for this repo's own provenance-chain convention (see `CONTEXT.md`), not because the spec requires it. - **Contributing files:** SKILL.md - **Status:** `extracted` diff --git a/plugins/kyberforge/skills/forge/references/version-bump.md b/plugins/kyberforge/skills/forge/references/version-bump.md new file mode 100644 index 0000000..8a9662a --- /dev/null +++ b/plugins/kyberforge/skills/forge/references/version-bump.md @@ -0,0 +1,37 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Bumping the package version after a route + +Reached from `SKILL.md` Step 3 when a route has finished and its completion message carries no +evidence that the package version was bumped. The author skills bump it themselves in some flows, +so check their output before doing anything here — a second bump for one artifact 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 X.Y.Z to X.Y.Z to reflect the new +`<artifact-name>`." -- 2.43.0 From aa982b9d267232999066bb44dc9cabee29617a9f Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:31:33 +0000 Subject: [PATCH 38/89] refactor(kyberforge): retrofit apm-install to the ADR-0020 contract Description 514 -> 213 chars, Gotchas 5 entries/47% -> 2 entries/17%. Body 350 -> 397 words: three Gotchas fold into the steps they gate, and the repairs below add back what the fold dropped. Cuts the second trigger register, the runtime enumeration (still named in the body and README) and the enumeration inside the boundary clause. Fixes four defects the first pass introduced: - The mirror bullet claimed the piped one-liner "ignores" VERSION and GITHUB_URL. The installer reads both from the environment and its own usage header documents VERSION working through the pipe. The real constraint is that an air-gapped host cannot reach aka.ms, so the script must be on disk. Also corrects the variable names -- APM_RELEASE_BASE_URL is the mirror base, GITHUB_URL is the Enterprise host. - "If apm --version already answers, skip to Step 2" was unconditional on intent, so a pin or upgrade request routed past the only pin instruction in the skill. Now gated on intent. - The PEP 668 rule was demoted to post-failure recovery, leaving a routing rule that sent a Debian box into a command that hard-fails. The prohibition is back on the pip bullet, before the choice. - The apm-is-not-a-runtime Gotcha lost its operative clause. The two step headings cited as carrying it already existed pre-retrofit, so nothing had replaced it and nothing stated when Step 2 is required. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- .../.apm/skills/apm-install/README.md | 2 +- .../.apm/skills/apm-install/SKILL.md | 36 +++++++++---------- .../kyberforge/skills/apm-install/README.md | 2 +- .../kyberforge/skills/apm-install/SKILL.md | 36 +++++++++---------- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/apm-install/README.md b/plugins/kyberforge/.apm/skills/apm-install/README.md index 5cacb8f..995a182 100644 --- a/plugins/kyberforge/.apm/skills/apm-install/README.md +++ b/plugins/kyberforge/.apm/skills/apm-install/README.md @@ -4,7 +4,7 @@ Installs and configures the `apm` (Agent Package Manager) CLI and the agent runt ## What it does -Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). +Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip/pipx), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). ## Usage diff --git a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md index abb520a..9b249ea 100644 --- a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md @@ -1,13 +1,9 @@ --- 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, or auditing apm packages -> `apm-workflow`. metadata: category: apm source_keys: @@ -16,13 +12,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 +26,17 @@ curl -sSL https://aka.ms/apm-unix | sh ``` Escape hatches — combine as needed: -- Pin a version: append `@vX.Y.Z` to the piped script's arguments, e.g. `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. -- Custom install directory: set `APM_INSTALL_DIR` on the piped script's command, e.g. `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. -- Air-gapped / GitHub Enterprise mirror: download `install.sh` first, then run it with `GITHUB_URL` and `VERSION` set, e.g. `GITHUB_URL=https://github.corp.com VERSION=v1.2.3 sh install.sh`. -- pip (Python 3.10+ environments): `pip install apm-cli`. -- pipx (externally-managed/PEP 668 environments where plain `pip install` fails, e.g. Debian): `pipx install apm-cli`. -- Manual: download the platform archive from the GitHub releases page, extract, place the binary on `PATH`. + +- **Pin a version** — append `@vX.Y.Z` to the piped script's arguments: `curl -sSL https://aka.ms/apm-unix | sh -s -- @v1.2.3`. +- **Custom install directory** — set `APM_INSTALL_DIR` on the piped script's command: `curl -sSL https://aka.ms/apm-unix | APM_INSTALL_DIR=$HOME/.local/bin sh`. +- **Air-gapped mirror / GitHub Enterprise** — an air-gapped host cannot reach `aka.ms` at all, so get `install.sh` onto the box and run it from disk instead of piping. Point it at the mirror with `APM_RELEASE_BASE_URL` and pin `VERSION`: `APM_RELEASE_BASE_URL=https://mirror.corp/apm VERSION=v1.2.3 sh install.sh`; add `APM_RELEASE_METADATA_URL` instead if you leave `VERSION` unset. `GITHUB_URL` is the GitHub Enterprise host, not a release mirror. All four are ordinary environment variables that also work through the pipe — running from disk is a network constraint, not a script one. +- **pip** — `pip install apm-cli` requires Python 3.10+. Not on an externally-managed (PEP 668) Python such as Debian or Ubuntu, where it hard-fails with `error: externally-managed-environment`; use pipx below. The quick-install script has no Python prerequisite, so prefer it unless the environment is pip-first. +- **pipx** — `pipx install apm-cli` on those PEP 668 environments. Same PyPI package, but pipx builds an isolated venv and exposes `apm` on `PATH`. +- **Manual** — download the platform archive from the GitHub releases page, extract, and place the binary on `PATH`. Verify with `apm --version`. -## Install or manage an agent runtime +## Step 2 — Install or manage an agent runtime Default: diff --git a/plugins/kyberforge/skills/apm-install/README.md b/plugins/kyberforge/skills/apm-install/README.md index 5cacb8f..995a182 100644 --- a/plugins/kyberforge/skills/apm-install/README.md +++ b/plugins/kyberforge/skills/apm-install/README.md @@ -4,7 +4,7 @@ Installs and configures the `apm` (Agent Package Manager) CLI and the agent runt ## What it does -Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). +Covers the two provisioning steps for working with apm: installing the `apm` binary itself (quick-install script, pinned version, air-gapped mirror, or pip/pipx), and installing/managing an agent runtime apm drives (`apm runtime setup copilot|codex|gemini|llm`, listing installed runtimes, checking which one `apm run` defaults to). ## Usage diff --git a/plugins/kyberforge/skills/apm-install/SKILL.md b/plugins/kyberforge/skills/apm-install/SKILL.md index abb520a..9b249ea 100644 --- a/plugins/kyberforge/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/skills/apm-install/SKILL.md @@ -1,13 +1,9 @@ --- 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, or auditing apm packages -> `apm-workflow`. metadata: category: apm source_keys: @@ -16,13 +12,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 +26,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: -- 2.43.0 From 264a5dbd670195ba2b157152bc4a6e2cde345f9c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:31:43 +0000 Subject: [PATCH 39/89] docs(kyberforge): refresh the apm-workflow exemplar figures after its retrofit body-discipline.md and contract.md both cite apm-workflow by hard number as the reference dispatch shape, and instruct authors to calibrate against its body word count. The retrofit in 1e4aab5 moved every one of those figures: body 421 -> 237, whole-file 554 -> 304, references 3,006 -> 3,222. Measured, not estimated. The quoted closing line was stale too -- it now carries a continuation clause, so it is quoted with an ellipsis rather than as a full sentence. These are positive exemplars in live guidance, so they are refreshed rather than pinned. That is the opposite treatment from the git-commits negative example in the same file, which is pinned to 5e23250 precisely so it keeps describing the pre-retrofit state. ADR-0020 carries the same figures and is deliberately left alone: it self-pins every citation to base commit f9b919d, and refreshing it would destroy the record of what the decision was taken against. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- .../.apm/skills/skill-audit/references/body-discipline.md | 8 ++++---- .../.apm/skills/skill-author/references/contract.md | 4 ++-- .../skills/skill-audit/references/body-discipline.md | 8 ++++---- .../kyberforge/skills/skill-author/references/contract.md | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index 0fa7971..d4a7945 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -80,13 +80,13 @@ 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 +The reference shape in this repo is `apm-workflow`: a **237-word body** dispatching to roughly +3,200 words of references across five mutually exclusive invocations. Its whole-file count is 304 +words — cite 237 when calibrating a body, or the conflation this section warns against reappears in the finding itself. Note its wiring: a three-column table (invocation, action, reference file) closed by one line, -*"Read only the reference file matching the requested action."* That is the endorsed shape, and it +*"Read only the reference file matching the requested action …"* That is the endorsed shape, and it is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. ## Gotchas sections diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md index 986514d..70723ef 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md @@ -120,8 +120,8 @@ A generic pointer ("see references/ for details") is a Vale error — the agent **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 +file. Exemplar: the `apm-workflow` skill — a **237-word body** dispatching to 3,222 words of +references. Calibrate against 237: that file's whole-file count is 304 words, and aiming at that number instead overshoots the body budget by ~30%. **Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index 0fa7971..d4a7945 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -80,13 +80,13 @@ 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 +The reference shape in this repo is `apm-workflow`: a **237-word body** dispatching to roughly +3,200 words of references across five mutually exclusive invocations. Its whole-file count is 304 +words — cite 237 when calibrating a body, or the conflation this section warns against reappears in the finding itself. Note its wiring: a three-column table (invocation, action, reference file) closed by one line, -*"Read only the reference file matching the requested action."* That is the endorsed shape, and it +*"Read only the reference file matching the requested action …"* That is the endorsed shape, and it is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. ## Gotchas sections diff --git a/plugins/kyberforge/skills/skill-author/references/contract.md b/plugins/kyberforge/skills/skill-author/references/contract.md index 986514d..70723ef 100644 --- a/plugins/kyberforge/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/skills/skill-author/references/contract.md @@ -120,8 +120,8 @@ A generic pointer ("see references/ for details") is a Vale error — the agent **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 +file. Exemplar: the `apm-workflow` skill — a **237-word body** dispatching to 3,222 words of +references. Calibrate against 237: that file's whole-file count is 304 words, and aiming at that number instead overshoots the body budget by ~30%. **Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after -- 2.43.0 From 79c60715dcad9c4dccc3ce75b70b2a42432f8280 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:42:51 +0000 Subject: [PATCH 40/89] fix(gates): restore byte-identity of the shared ADR-0020 boundary resolver 915eb09 rewrote the bare-arrow carve-out comment in scripts/skill-size-check.sh without pasting the block over the two other copies, so the resolver stood at 764 lines in the hook and 755 in both audit validators. tests/test-adr0020-contract.sh exists to catch exactly this and did -- it was the regression behind that suite's failure, and I reported the suite green after 915eb09 without re-running it. No behaviour changes: the drift was comment-only. Restoring identity keeps the invariant the test enforces, which matters because the three copies are the only thing making the hook and the two validators agree on what a boundary target is. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- .../.apm/skills/agent-audit/scripts/validate.sh | 13 +++++++++++-- .../.apm/skills/skill-audit/scripts/validate.sh | 13 +++++++++++-- .../skills/agent-audit/scripts/validate.sh | 13 +++++++++++-- .../skills/skill-audit/scripts/validate.sh | 13 +++++++++++-- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index fc0f711..b13a210 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -446,8 +446,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 diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index f9c0df7..e169390 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -372,8 +372,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 diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh index fc0f711..b13a210 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh @@ -446,8 +446,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 diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh index f9c0df7..e169390 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh @@ -372,8 +372,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 -- 2.43.0 From b25412bf39a11426c04a45e6df5806d52ecb5037 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 16:42:59 +0000 Subject: [PATCH 41/89] docs: record that the ADR-0020 corpus is clean, and what the gates still miss Wave 4 closed the last three FAILs, so the session rule no longer describes a grandfathered set: all 39 skills clear both tiers, 0 descriptions over 400 chars and 0 bodies over 900 words. Preload tax 21,033 -> 10,201 chars (~2,550 tokens), under the 12,000 success criterion in #99. The rule now says what that changes for the reader: nothing is grandfathered, so the gates bite on first commit rather than waiting for a retrofit. Also names the second blind spot, found this wave. The Kyberforge Vale style is scoped [**/SKILL.md], so every references/ file is unlinted -- and the contract's own remedy is to move prose into references/, which moves it out of the prose gate's reach. forge's retrofit relocated ~900 words that way and the moved prose carried a rule violation Vale would have caught in a SKILL.md. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6d25159..078ffd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline.** Three skills still exceed a FAIL tier, all in `kyberforge`: `apm-workflow` (817-char description), `forge` (648 chars, 1,093-word body) and `apm-install` (514 chars). Editing any of those three *for any reason* means retrofitting it to the contract first — a one-line fix cannot be committed until the skill complies. Deliberate; tracked as Gitea issue #99, which is retrofitting the corpus plugin by plugin and has `kyberforge` left. **No routing target dangles any more**, and `tests/test-adr0020-targets.sh` now pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. The `Kyberforge.CompositionNote` Vale rule fires nowhere, but `skill-size-check` does not cover the Vale half and any new description can reintroduce it, so check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Issue #99 retrofitted them plugin by plugin and `kyberforge` was the last wave. Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and the `Kyberforge` style is scoped `[**/SKILL.md]`, so every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. Check both: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. -- 2.43.0 From bdff6fdb3cb379e54fb27b06400894d701751faf Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 17:04:45 +0000 Subject: [PATCH 42/89] refactor(bin): make caveman hand-invoked only Sets `disable-model-invocation: true`, so the host withholds caveman from the model-visible skill listing. It stops paying preload tax and can only be reached by typing /caveman. The reason is not the 287 characters. caveman's description was almost entirely a trigger list -- "caveman mode", "talk like caveman", "less tokens", "be brief" -- and "be brief" is a false-positive magnet: a user asking for brevity wants short answers, not dropped articles and `Respond terse like smart caveman`. Removing the skill from the router deletes the bad trigger. User's call, made explicitly. Safe to flag because the flag also hard-blocks the Skill tool, so any inbound route from another skill would break. Re-checked all 39 descriptions and bodies after four waves of retrofit rewrites: every mention of caveman is documentation, research notes or the lockfile. Nothing routes to it. The description is rewritten as human-facing text, since it is no longer a routing surface -- it now says how to turn the mode on and off rather than listing phrases for a reader that can no longer see it. The two remaining SUGGESTIONs are #108: the boundary-clause check fires on hand-invoked skills, which ADR-0020 contractually exempts. Advisory, and not to be "fixed" in the skill. Preload tax 10,002 -> 9,732 chars across 37 model-visible skills. Refs #99 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWb5RQgCL1ye7cGp2RPb2u --- plugins/bin/.apm/skills/caveman/SKILL.md | 9 +++++---- plugins/bin/skills/caveman/SKILL.md | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/bin/.apm/skills/caveman/SKILL.md b/plugins/bin/.apm/skills/caveman/SKILL.md index 85770a3..a4bb498 100644 --- a/plugins/bin/.apm/skills/caveman/SKILL.md +++ b/plugins/bin/.apm/skills/caveman/SKILL.md @@ -1,10 +1,11 @@ --- name: caveman +disable-model-invocation: true description: > - Ultra-compressed communication mode. Cuts token usage ~75% by dropping - filler, articles, and pleasantries while keeping full technical accuracy. - Use when user says "caveman mode", "talk like caveman", "use caveman", - "less tokens", "be brief", or invokes /caveman. + Ultra-compressed output mode: drops articles, filler and pleasantries while + keeping technical substance exact. Cuts token usage by roughly 75%. Hand-invoked + only — type /caveman to turn it on, "stop caveman" or "normal mode" to turn it + off. Stays active across turns until you do. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. diff --git a/plugins/bin/skills/caveman/SKILL.md b/plugins/bin/skills/caveman/SKILL.md index 85770a3..a4bb498 100644 --- a/plugins/bin/skills/caveman/SKILL.md +++ b/plugins/bin/skills/caveman/SKILL.md @@ -1,10 +1,11 @@ --- name: caveman +disable-model-invocation: true description: > - Ultra-compressed communication mode. Cuts token usage ~75% by dropping - filler, articles, and pleasantries while keeping full technical accuracy. - Use when user says "caveman mode", "talk like caveman", "use caveman", - "less tokens", "be brief", or invokes /caveman. + Ultra-compressed output mode: drops articles, filler and pleasantries while + keeping technical substance exact. Cuts token usage by roughly 75%. Hand-invoked + only — type /caveman to turn it on, "stop caveman" or "normal mode" to turn it + off. Stays active across turns until you do. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. -- 2.43.0 From 38efd2be67f6dd4cd0361c2570d0bce521fd4c60 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 17:45:50 +0000 Subject: [PATCH 43/89] fix(skills): collapse verb-enumerated descriptions to one capability clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent audit of the ADR-0020 retrofit (issue #99) found that git-submodules, git-worktrees, and gitea-files each collapsed their description length correctly during retrofit but left the capability clause as a verb enumeration (e.g. "Create, list, lock, move, remove, prune, or repair") instead of ADR-0020's required single clause. The deterministic char-count gate can't catch this — it's a qualitative rubric violation the retrofit commits' own messages never claimed to address, only measurable length/word-count fixes. Validated clean via skill-audit and skill-size-check after the fix; boundary clauses and routing targets left untouched. Refs #99 --- plugins/git/.apm/skills/git-submodules/SKILL.md | 4 ++-- plugins/git/.apm/skills/git-worktrees/SKILL.md | 4 ++-- plugins/git/skills/git-submodules/SKILL.md | 4 ++-- plugins/git/skills/git-worktrees/SKILL.md | 4 ++-- plugins/gitea/.apm/skills/gitea-files/SKILL.md | 6 +++--- plugins/gitea/skills/gitea-files/SKILL.md | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/plugins/git/.apm/skills/git-submodules/SKILL.md b/plugins/git/.apm/skills/git-submodules/SKILL.md index 1fc6d8d..60b86b4 100644 --- a/plugins/git/.apm/skills/git-submodules/SKILL.md +++ b/plugins/git/.apm/skills/git-submodules/SKILL.md @@ -2,8 +2,8 @@ name: git-submodules description: > - Use when managing Git submodules — adding, updating, pinning, inspecting, - repointing, or removing a nested repository inside a superproject. + Use when managing Git submodules — the full lifecycle of a nested + repository inside a superproject. Not multiple checkouts of one repo -> `git-worktrees`. Not the superproject's own remotes -> `git-remotes`. diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index 51a1980..2a74445 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -2,8 +2,8 @@ name: git-worktrees description: > - Use when working on several branches at once without stashing. - Create, list, lock, move, remove, prune, or repair git worktrees. + Use when working on several branches at once without stashing — + manages the full lifecycle of a git worktree. Not ordinary branch switching or checkout -> `git-branches`. Not interactive multi-step git guidance -> `git-workflow`. diff --git a/plugins/git/skills/git-submodules/SKILL.md b/plugins/git/skills/git-submodules/SKILL.md index 1fc6d8d..60b86b4 100644 --- a/plugins/git/skills/git-submodules/SKILL.md +++ b/plugins/git/skills/git-submodules/SKILL.md @@ -2,8 +2,8 @@ name: git-submodules description: > - Use when managing Git submodules — adding, updating, pinning, inspecting, - repointing, or removing a nested repository inside a superproject. + Use when managing Git submodules — the full lifecycle of a nested + repository inside a superproject. Not multiple checkouts of one repo -> `git-worktrees`. Not the superproject's own remotes -> `git-remotes`. diff --git a/plugins/git/skills/git-worktrees/SKILL.md b/plugins/git/skills/git-worktrees/SKILL.md index 51a1980..2a74445 100644 --- a/plugins/git/skills/git-worktrees/SKILL.md +++ b/plugins/git/skills/git-worktrees/SKILL.md @@ -2,8 +2,8 @@ name: git-worktrees description: > - Use when working on several branches at once without stashing. - Create, list, lock, move, remove, prune, or repair git worktrees. + Use when working on several branches at once without stashing — + manages the full lifecycle of a git worktree. Not ordinary branch switching or checkout -> `git-branches`. Not interactive multi-step git guidance -> `git-workflow`. diff --git a/plugins/gitea/.apm/skills/gitea-files/SKILL.md b/plugins/gitea/.apm/skills/gitea-files/SKILL.md index 18e47cb..ace6379 100644 --- a/plugins/gitea/.apm/skills/gitea-files/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-files/SKILL.md @@ -2,9 +2,9 @@ name: gitea-files description: > - Use when reading or writing files in a Gitea repository rather than on the local filesystem — - read, list, walk the tree, create, update, or delete — even when the user does not say "Gitea". - Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. + Use when reading or writing files or directories in a Gitea repository via the MCP server, + rather than the local filesystem — even when the user does not say "Gitea". Not commit + history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue diff --git a/plugins/gitea/skills/gitea-files/SKILL.md b/plugins/gitea/skills/gitea-files/SKILL.md index 18e47cb..ace6379 100644 --- a/plugins/gitea/skills/gitea-files/SKILL.md +++ b/plugins/gitea/skills/gitea-files/SKILL.md @@ -2,9 +2,9 @@ name: gitea-files description: > - Use when reading or writing files in a Gitea repository rather than on the local filesystem — - read, list, walk the tree, create, update, or delete — even when the user does not say "Gitea". - Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. + Use when reading or writing files or directories in a Gitea repository via the MCP server, + rather than the local filesystem — even when the user does not say "Gitea". Not commit + history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue -- 2.43.0 From 92ba9abe7cf00075aa47ded065b71ed6aa3ad2d1 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 17:45:56 +0000 Subject: [PATCH 44/89] fix(diagnose): restore skill-root-relative script path 00c1e6b (the ADR-0020 retrofit of diagnose) moved a bullet referencing scripts/hitl-loop.template.sh out of SKILL.md and into the new references/feedback-loops.md, and in the move flipped the correct skill-root-relative path into an incorrect parent-relative one (../scripts/...) -- despite that commit's own message claiming to fix "a script path that did not resolve." References in this skill are written relative to the skill root regardless of which file carries them, matching the convention used throughout SKILL.md. Found via an independent post-closure audit of issue #99; validated clean via skill-audit afterward. Refs #99 --- plugins/bin/.apm/skills/diagnose/references/feedback-loops.md | 2 +- plugins/bin/skills/diagnose/references/feedback-loops.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md index 899c95a..d3d83c2 100644 --- a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md +++ b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md @@ -13,7 +13,7 @@ A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for th 7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. 8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. 9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `../scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. ## Iterate on the loop itself diff --git a/plugins/bin/skills/diagnose/references/feedback-loops.md b/plugins/bin/skills/diagnose/references/feedback-loops.md index 899c95a..d3d83c2 100644 --- a/plugins/bin/skills/diagnose/references/feedback-loops.md +++ b/plugins/bin/skills/diagnose/references/feedback-loops.md @@ -13,7 +13,7 @@ A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for th 7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. 8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. 9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `../scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. ## Iterate on the loop itself -- 2.43.0 From 59aaec4ed693fe3d0307dcebbec088da32ae57be Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 19:31:15 +0000 Subject: [PATCH 45/89] fix(gitea-branches): repoint dangling overview.md citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit references/commits.md cited overview.md as the authority for a scope-gating claim, but no such file exists in this skill's package — the only overview.md is an external research doc not shipped with the skill. Repoint to branches.md's own Token scope section, which states and confirms the same principle, and drop the unverifiable write:repository enumeration detail no file in this skill actually makes. Found by an independent post-closure audit of #99 (agent-audit + skill-audit re-run against every changed skill/agent). --- .../.apm/skills/gitea-branches/references/commits.md | 8 ++++---- plugins/gitea/skills/gitea-branches/references/commits.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md index 19f8b3c..12c1d68 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md @@ -64,10 +64,10 @@ edge cases, `get_commit` will not. ## Token scope Both tools are believed to require `write:repository`, even though they're read-only — inferred by -analogy with the scope-gating principle in `overview.md` (Gitea gates reads behind write scope for -repo-scoped operations), not a claim `overview.md` makes for commits by name: its explicit -`write:repository` enumeration lists PR, branch, file, release, and tag operations, but doesn't -mention commits. An earlier version of this doc claimed `write:issue` alone worked, based on +analogy with the scope-gating principle confirmed for branch operations in `branches.md`'s Token +scope section (Gitea gates reads behind write scope for repo-scoped operations), not a claim any +doc in this skill makes for commits by name: nothing here enumerates commits under +`write:repository` explicitly. An earlier version of this doc claimed `write:issue` alone worked, based on empirical testing under a token that held both `write:issue` and `write:repository` simultaneously — that test didn't isolate the variable either. Treat this as unverified until tested under a token scoped to `write:issue` only (no `write:repository`). diff --git a/plugins/gitea/skills/gitea-branches/references/commits.md b/plugins/gitea/skills/gitea-branches/references/commits.md index 19f8b3c..12c1d68 100644 --- a/plugins/gitea/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/skills/gitea-branches/references/commits.md @@ -64,10 +64,10 @@ edge cases, `get_commit` will not. ## Token scope Both tools are believed to require `write:repository`, even though they're read-only — inferred by -analogy with the scope-gating principle in `overview.md` (Gitea gates reads behind write scope for -repo-scoped operations), not a claim `overview.md` makes for commits by name: its explicit -`write:repository` enumeration lists PR, branch, file, release, and tag operations, but doesn't -mention commits. An earlier version of this doc claimed `write:issue` alone worked, based on +analogy with the scope-gating principle confirmed for branch operations in `branches.md`'s Token +scope section (Gitea gates reads behind write scope for repo-scoped operations), not a claim any +doc in this skill makes for commits by name: nothing here enumerates commits under +`write:repository` explicitly. An earlier version of this doc claimed `write:issue` alone worked, based on empirical testing under a token that held both `write:issue` and `write:repository` simultaneously — that test didn't isolate the variable either. Treat this as unverified until tested under a token scoped to `write:issue` only (no `write:repository`). -- 2.43.0 From 0c0df46ac9a07661383d65e85bcb2f3a416cd135 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 19:31:18 +0000 Subject: [PATCH 46/89] fix(gitea-releases): hedge the unconfirmed tag-deletion direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retrofit (dfacf05) collapsed a Gotcha into a bidirectional claim — "deleting a tag never deletes the release wrapping it" — that references/call-signatures.md never confirms; that file explicitly marks the reverse direction unconfirmed and "the more dangerous direction to get wrong." State only the confirmed direction (deleting a release doesn't delete its tag) and flag the reverse as unconfirmed with a verification step, on a destructive, irreversible operation. Found by an independent post-closure audit of #99 (agent-audit + skill-audit re-run against every changed skill/agent). --- plugins/gitea/.apm/skills/gitea-releases/SKILL.md | 2 +- plugins/gitea/skills/gitea-releases/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md index a573c4c..37a3444 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md @@ -17,7 +17,7 @@ metadata: ## Gotchas -- **Deleting a release never deletes its tag, and deleting a tag never deletes the release wrapping it.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. +- **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. - **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. - **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. diff --git a/plugins/gitea/skills/gitea-releases/SKILL.md b/plugins/gitea/skills/gitea-releases/SKILL.md index a573c4c..37a3444 100644 --- a/plugins/gitea/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/skills/gitea-releases/SKILL.md @@ -17,7 +17,7 @@ metadata: ## Gotchas -- **Deleting a release never deletes its tag, and deleting a tag never deletes the release wrapping it.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. +- **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. - **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. - **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. -- 2.43.0 From 6cb47f81f64f9bc5b1cc25cf43224d462068c57c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 19:31:21 +0000 Subject: [PATCH 47/89] fix(apm-workflow): surface the registries precondition in compile/install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md says the apm experimental enable registries precondition applies "anywhere — configure, install or publish," but only configure.md actually carried it. compile.md's Publish flow and install.md's dependency resolution can both hit a named registry and silently no-op with no error if the precondition was never run, yet neither file mentioned it — contradicting the skill's own promise that each reference file is self-contained for its concern. Add a one-line cross-reference to configure.md's Gotchas in each. Found by an independent post-closure audit of #99 (agent-audit + skill-audit re-run against every changed skill/agent). --- .../kyberforge/.apm/skills/apm-workflow/references/compile.md | 2 ++ .../kyberforge/.apm/skills/apm-workflow/references/install.md | 2 ++ plugins/kyberforge/skills/apm-workflow/references/compile.md | 2 ++ plugins/kyberforge/skills/apm-workflow/references/install.md | 2 ++ 4 files changed, 8 insertions(+) diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md index d9b77d9..ae801ea 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md @@ -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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. + ## Run ```bash diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md index 760c44e..8f30470 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md @@ -18,3 +18,5 @@ With no arguments, resolves and installs everything declared under `dependencies `--update` is the escape hatch for a lockfile hash mismatch against upstream — normal `apm install` treats that as drift and won't silently accept it; see `references/audit.md` for the CI-side check (`apm install --frozen`) that fails instead of re-resolving. `--target agent-skills` generates the vendor-neutral output directory instead of a Claude/Copilot-specific one — for IDE-agnostic tool support. + +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. diff --git a/plugins/kyberforge/skills/apm-workflow/references/compile.md b/plugins/kyberforge/skills/apm-workflow/references/compile.md index d9b77d9..ae801ea 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/skills/apm-workflow/references/compile.md @@ -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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. + ## Run ```bash diff --git a/plugins/kyberforge/skills/apm-workflow/references/install.md b/plugins/kyberforge/skills/apm-workflow/references/install.md index 760c44e..8f30470 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/skills/apm-workflow/references/install.md @@ -18,3 +18,5 @@ With no arguments, resolves and installs everything declared under `dependencies `--update` is the escape hatch for a lockfile hash mismatch against upstream — normal `apm install` treats that as drift and won't silently accept it; see `references/audit.md` for the CI-side check (`apm install --frozen`) that fails instead of re-resolving. `--target agent-skills` generates the vendor-neutral output directory instead of a Claude/Copilot-specific one — for IDE-agnostic tool support. + +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. -- 2.43.0 From ddf85518c7785cc26b303d246acc3f3c6128e9fe Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:50:36 +0000 Subject: [PATCH 48/89] fix(git-worktrees): correct the remote-tracking and repair claims The dispatch row "Create tracking a remote branch" prescribed `git worktree add <path> <remote>/<branch>`. Per git-worktree(1) the tracking DWIM fires only when <commit-ish> is a bare branch name that is NOT found locally, no -b/-B/--detach is given, and exactly one remote has a matching name; only then is it equivalent to `git worktree add --track -b <branch> <path> <remote>/<branch>`. An explicit <remote>/<branch> is found, so that precondition fails and no branch is created: the result is a detached HEAD with no upstream. Commits made in it become unreachable once the worktree is removed or HEAD moves, and push needs an explicit refspec. Only the -d row was flagged detached. The table now names --track -b as the always-correct form, keeps the bare name shortcut with its precondition stated, and adds a Never row for the <remote>/<branch> spelling. The single-remote and checkout.defaultRemote preconditions move into the body, since a reader who trusts the table never follows the reference pointer. Also corrects `git worktree repair`: the no-argument form is the only cwd-dependent one, and `repair <path>...` runs from any worktree. The claim that running it from the wrong directory "reports nothing and fixes nothing" has no basis in the manual and is removed. Found by an independent review of this branch. Refs #99 --- .../git/.apm/skills/git-worktrees/SKILL.md | 10 ++-- .../git-worktrees/references/worktrees.md | 56 ++++++++++++++++--- plugins/git/skills/git-worktrees/SKILL.md | 10 ++-- .../git-worktrees/references/worktrees.md | 56 ++++++++++++++++--- 4 files changed, 106 insertions(+), 26 deletions(-) diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index 2a74445..f69223c 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -24,18 +24,19 @@ metadata: | Operation | Run | |---|---| -| Create on an existing branch | `git worktree add <path> <branch>` | +| Create on a branch that already exists locally | `git worktree add <path> <branch>` | | Create on a new branch | `git worktree add -b <branch> <path>` | | Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | | Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | -| Create tracking a remote branch | `git worktree add <path> <remote>/<branch>` | -| Throwaway experiment, no branch | `git worktree add -d <path>` | +| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** when `<branch>` has no local copy (gate below) | +| Throwaway experiment, no branch | `git worktree add -d <path>` — detached HEAD | +| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above | | List | `git worktree list -v`, or `--porcelain -z` to parse | | Lock or unlock | `git worktree lock [--reason <str>] <path>` / `git worktree unlock <path>` | | Move | `git worktree move <from> <to>` | | Remove | `git worktree remove <path>` | | Prune stale metadata | `git worktree prune --dry-run`, then without the flag | -| Repair after a manual move | `git worktree repair [<path>]` — from the main worktree to fix all links, or from the moved worktree itself | +| Repair after a manual move | `git worktree repair` — in the main worktree if *it* moved, or inside a linked worktree that moved. `git worktree repair <path>...` — from any worktree, naming each moved linked worktree's new path | If the operation needs anything the table does not carry — the full `add` flag table, orphan branches, sparse-checkout, locking for removable media, remote @@ -47,6 +48,7 @@ Gates: - **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. - **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. - **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. +- **`add` — the bare-name tracking shortcut needs exactly one remote.** `git worktree add <path> <branch>` sets up tracking only when `<branch>` is absent locally, no `-b`/`-B`/`-d` is given, and exactly one remote carries the name. With several, it fires only if `checkout.defaultRemote` names one. When the remote is ambiguous or unknown, use `--track -b`. - **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. ## Step 2 — Report diff --git a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md index ebade88..a5cedef 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md @@ -15,16 +15,29 @@ or moved. Every other worktree is a **linked worktree** created by `git worktree ## `add` forms ```bash -git worktree add <path> <branch> # check out an existing branch — non-destructive +git worktree add <path> <branch> # <branch> exists locally: check it out — non-destructive git worktree add -b <branch> <path> # create a new branch; fails if it exists git worktree add <path> # branch named after $(basename <path>): checked out # if it exists, else created from HEAD git worktree add -B <branch> <path> # create the branch, or reset an existing one to HEAD, # discarding the commits it carried -git worktree add <path> <remote>/<branch> # track a remote branch +git worktree add --track -b <branch> <path> <remote>/<branch> + # new local branch tracking the remote — always works +git worktree add <path> <branch> # <branch> absent locally and in exactly one remote: + # Git expands this to the --track -b form above git worktree add -d <path> # detached HEAD, no branch ``` +The same `git worktree add <path> <branch>` spelling appears twice above and does two +different things: it checks out a local branch when one exists, and only otherwise falls +through to the remote-tracking shortcut. Read the local branch list before relying on either. + +**Do not write `git worktree add <path> <remote>/<branch>`.** A remote-tracking ref resolves as a +commit-ish, so the tracking shortcut never fires and the worktree lands on a **detached HEAD with +no local branch and no upstream** — commits there go unreachable once HEAD moves or the worktree is +removed, and `git push` fails without an explicit refspec. That spelling is correct only as the +final argument of the `--track -b` form. + ## Full `add` flag table | Flag | Meaning | @@ -69,19 +82,40 @@ git worktree unlock <path> # when reconnected ## Remote-branch disambiguation ```bash -git worktree add <path> <remote>/<branch> +git worktree add --track -b <branch> <path> <remote>/<branch> # explicit: no guessing at all +git worktree add <path> <branch> # shortcut: needs one clear remote ``` -For ambiguous names across remotes, `checkout.defaultRemote` config disambiguates explicitly, or `--guess-remote` auto-matches by path basename (default controlled by `worktree.guessRemote` config). If a branch name matches multiple remotes during `worktree add` and neither is set, Git refuses rather than guessing. +The shortcut fires only when `<branch>` is not found locally, none of `-b`/`-B`/`--detach` were +given, and a tracking branch of that name exists in exactly one remote. When several remotes carry +the name, `checkout.defaultRemote` picks one for disambiguation purposes; with no such setting the +shortcut has no single remote to resolve against and does not apply. + +`--guess-remote` covers the *other* spelling — `git worktree add <path>` with no `<commit-ish>` at +all. It bases the new branch on the remote-tracking branch matching `$(basename <path>)` when +exactly one remote has it, and marks that branch as upstream. Its default comes from the +`worktree.guessRemote` config. ## Repair after a manual move ```bash -git worktree repair # run from the main worktree: fixes the links to every linked worktree -git worktree repair <path> # run from a moved linked worktree: fixes its own pointer back to main +git worktree repair # the MAIN worktree moved: run it there to reconnect every linked + # worktree back to the main worktree +git worktree repair # a LINKED worktree moved: run it inside that recently-moved worktree +git worktree repair <path>... # reconnect a specific linked worktree — runnable from any worktree, + # naming each moved tree's new path ``` -`repair` reestablishes the bidirectional pointers a manual move breaks, but only for the side it is -run from. Run it from the wrong directory and it reports nothing and fixes nothing. +Which form applies depends on what moved: + +| What moved | Remedy | +|---|---| +| The main worktree (or bare repo) | `git worktree repair` in the main worktree | +| One linked worktree | `git worktree repair` inside that worktree | +| Several linked worktrees | `git worktree repair <path>...` from any worktree, listing each new path | +| Both main and linked worktrees | `git worktree repair <path>...` in the main worktree, naming each linked worktree's new path — this restores the connections in both directions | + +Only the no-argument form is tied to the current directory. The `<path>...` form is not — it +reestablishes the connection to every path you name, run from any worktree. ## Configuration @@ -111,6 +145,10 @@ git worktree remove ../temp context switch: ```bash -git worktree add ../review-pr-123 origin/feature-xyz +git worktree add ../review-pr-123 origin/feature-xyz # detached HEAD — read-only review +git worktree add --track -b feature-xyz ../review-pr-123 origin/feature-xyz # if you will commit # open ../review-pr-123 in a second editor window or terminal ``` + +Pick the second form the moment you intend to push anything back: the first leaves no branch to +push and no upstream to push to. diff --git a/plugins/git/skills/git-worktrees/SKILL.md b/plugins/git/skills/git-worktrees/SKILL.md index 2a74445..f69223c 100644 --- a/plugins/git/skills/git-worktrees/SKILL.md +++ b/plugins/git/skills/git-worktrees/SKILL.md @@ -24,18 +24,19 @@ metadata: | Operation | Run | |---|---| -| Create on an existing branch | `git worktree add <path> <branch>` | +| Create on a branch that already exists locally | `git worktree add <path> <branch>` | | Create on a new branch | `git worktree add -b <branch> <path>` | | Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | | Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | -| Create tracking a remote branch | `git worktree add <path> <remote>/<branch>` | -| Throwaway experiment, no branch | `git worktree add -d <path>` | +| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** when `<branch>` has no local copy (gate below) | +| Throwaway experiment, no branch | `git worktree add -d <path>` — detached HEAD | +| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above | | List | `git worktree list -v`, or `--porcelain -z` to parse | | Lock or unlock | `git worktree lock [--reason <str>] <path>` / `git worktree unlock <path>` | | Move | `git worktree move <from> <to>` | | Remove | `git worktree remove <path>` | | Prune stale metadata | `git worktree prune --dry-run`, then without the flag | -| Repair after a manual move | `git worktree repair [<path>]` — from the main worktree to fix all links, or from the moved worktree itself | +| Repair after a manual move | `git worktree repair` — in the main worktree if *it* moved, or inside a linked worktree that moved. `git worktree repair <path>...` — from any worktree, naming each moved linked worktree's new path | If the operation needs anything the table does not carry — the full `add` flag table, orphan branches, sparse-checkout, locking for removable media, remote @@ -47,6 +48,7 @@ Gates: - **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. - **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. - **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. +- **`add` — the bare-name tracking shortcut needs exactly one remote.** `git worktree add <path> <branch>` sets up tracking only when `<branch>` is absent locally, no `-b`/`-B`/`-d` is given, and exactly one remote carries the name. With several, it fires only if `checkout.defaultRemote` names one. When the remote is ambiguous or unknown, use `--track -b`. - **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. ## Step 2 — Report diff --git a/plugins/git/skills/git-worktrees/references/worktrees.md b/plugins/git/skills/git-worktrees/references/worktrees.md index ebade88..a5cedef 100644 --- a/plugins/git/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/skills/git-worktrees/references/worktrees.md @@ -15,16 +15,29 @@ or moved. Every other worktree is a **linked worktree** created by `git worktree ## `add` forms ```bash -git worktree add <path> <branch> # check out an existing branch — non-destructive +git worktree add <path> <branch> # <branch> exists locally: check it out — non-destructive git worktree add -b <branch> <path> # create a new branch; fails if it exists git worktree add <path> # branch named after $(basename <path>): checked out # if it exists, else created from HEAD git worktree add -B <branch> <path> # create the branch, or reset an existing one to HEAD, # discarding the commits it carried -git worktree add <path> <remote>/<branch> # track a remote branch +git worktree add --track -b <branch> <path> <remote>/<branch> + # new local branch tracking the remote — always works +git worktree add <path> <branch> # <branch> absent locally and in exactly one remote: + # Git expands this to the --track -b form above git worktree add -d <path> # detached HEAD, no branch ``` +The same `git worktree add <path> <branch>` spelling appears twice above and does two +different things: it checks out a local branch when one exists, and only otherwise falls +through to the remote-tracking shortcut. Read the local branch list before relying on either. + +**Do not write `git worktree add <path> <remote>/<branch>`.** A remote-tracking ref resolves as a +commit-ish, so the tracking shortcut never fires and the worktree lands on a **detached HEAD with +no local branch and no upstream** — commits there go unreachable once HEAD moves or the worktree is +removed, and `git push` fails without an explicit refspec. That spelling is correct only as the +final argument of the `--track -b` form. + ## Full `add` flag table | Flag | Meaning | @@ -69,19 +82,40 @@ git worktree unlock <path> # when reconnected ## Remote-branch disambiguation ```bash -git worktree add <path> <remote>/<branch> +git worktree add --track -b <branch> <path> <remote>/<branch> # explicit: no guessing at all +git worktree add <path> <branch> # shortcut: needs one clear remote ``` -For ambiguous names across remotes, `checkout.defaultRemote` config disambiguates explicitly, or `--guess-remote` auto-matches by path basename (default controlled by `worktree.guessRemote` config). If a branch name matches multiple remotes during `worktree add` and neither is set, Git refuses rather than guessing. +The shortcut fires only when `<branch>` is not found locally, none of `-b`/`-B`/`--detach` were +given, and a tracking branch of that name exists in exactly one remote. When several remotes carry +the name, `checkout.defaultRemote` picks one for disambiguation purposes; with no such setting the +shortcut has no single remote to resolve against and does not apply. + +`--guess-remote` covers the *other* spelling — `git worktree add <path>` with no `<commit-ish>` at +all. It bases the new branch on the remote-tracking branch matching `$(basename <path>)` when +exactly one remote has it, and marks that branch as upstream. Its default comes from the +`worktree.guessRemote` config. ## Repair after a manual move ```bash -git worktree repair # run from the main worktree: fixes the links to every linked worktree -git worktree repair <path> # run from a moved linked worktree: fixes its own pointer back to main +git worktree repair # the MAIN worktree moved: run it there to reconnect every linked + # worktree back to the main worktree +git worktree repair # a LINKED worktree moved: run it inside that recently-moved worktree +git worktree repair <path>... # reconnect a specific linked worktree — runnable from any worktree, + # naming each moved tree's new path ``` -`repair` reestablishes the bidirectional pointers a manual move breaks, but only for the side it is -run from. Run it from the wrong directory and it reports nothing and fixes nothing. +Which form applies depends on what moved: + +| What moved | Remedy | +|---|---| +| The main worktree (or bare repo) | `git worktree repair` in the main worktree | +| One linked worktree | `git worktree repair` inside that worktree | +| Several linked worktrees | `git worktree repair <path>...` from any worktree, listing each new path | +| Both main and linked worktrees | `git worktree repair <path>...` in the main worktree, naming each linked worktree's new path — this restores the connections in both directions | + +Only the no-argument form is tied to the current directory. The `<path>...` form is not — it +reestablishes the connection to every path you name, run from any worktree. ## Configuration @@ -111,6 +145,10 @@ git worktree remove ../temp context switch: ```bash -git worktree add ../review-pr-123 origin/feature-xyz +git worktree add ../review-pr-123 origin/feature-xyz # detached HEAD — read-only review +git worktree add --track -b feature-xyz ../review-pr-123 origin/feature-xyz # if you will commit # open ../review-pr-123 in a second editor window or terminal ``` + +Pick the second form the moment you intend to push anything back: the first leaves no branch to +push and no upstream to push to. -- 2.43.0 From 93d3263f8cf46a079afbf6f9cd52ff9a621830fb Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:50:45 +0000 Subject: [PATCH 49/89] fix(pc-run): rebind the clean gate and fixer-hook rule to every branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md tells a dispatching agent to read the matching reference file "and no other". The retrofit moved the `pre-commit clean` confirmation gate out of the always-loaded body into references/clean.md, but references/failure-patterns.md — loaded by the diagnosis route, not the clean route — prescribes `pre-commit clean` with no gate at all. So "why is this hook failing" could wipe the machine-wide cache at ~/.cache/pre-commit for every repo without asking. The gate returns to the body, where every branch loads it, and is restated at the point of use in failure-patterns.md. It is its own section rather than a Gotchas bullet because folding it in pushed the Gotchas ratio to 41%, whose only suggested remedy is moving it back to references/ — the move that caused this. The fixer-hook rule had the same shape: reachable only behind "if the cause is not obvious from the output", which is false precisely when pre-commit prints `- files were modified by this hook`. The fix (`git add -u && git commit`) and the prohibition on `pre-commit install -f` are now unconditional, and the two weakened pointers that stranded them are restored. Found by an independent review of this branch. Refs #99 --- plugins/git/.apm/skills/pc-run/SKILL.md | 13 +++++++++++-- .../skills/pc-run/references/failure-patterns.md | 7 ++++--- plugins/git/skills/pc-run/SKILL.md | 13 +++++++++++-- .../skills/pc-run/references/failure-patterns.md | 7 ++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/plugins/git/.apm/skills/pc-run/SKILL.md b/plugins/git/.apm/skills/pc-run/SKILL.md index e1da3a3..719b1ab 100644 --- a/plugins/git/.apm/skills/pc-run/SKILL.md +++ b/plugins/git/.apm/skills/pc-run/SKILL.md @@ -20,6 +20,15 @@ allowed-tools: Bash Read - The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. - Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. +- `- files were modified by this hook` is not a bug. A fixer hook (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json --autofix`) rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. The fix is to re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — that flag overwrites hook files in `.git/hooks/` and has nothing to do with re-staging. + +## Gate — `pre-commit clean` + +Confirm with the user before running `pre-commit clean`, on every path that reaches it — including when it turns up as the fix for a stale or broken environment. It wipes the whole cache at `~/.cache/pre-commit`, which is machine-wide and shared by every repo on the box, forcing every hook environment to be re-downloaded. + +> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + +`pre-commit gc` drops only unused environments and needs no confirmation — prefer it when the goal is just to reclaim disk. ## Route @@ -36,7 +45,7 @@ Determine intent from the user's request, then execute the matching operation. W | "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` — read `references/autoupdate.md` | | "gc", "garbage collect" | `pre-commit gc` — drops unused cached environments only, safe at any time | | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | -| "hooks aren't running", "hook never fires", "why did a hook fail" | Diagnose — read `references/failure-patterns.md` | +| "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | If the intent is ambiguous, default to `pre-commit run --all-files`. @@ -47,5 +56,5 @@ Default to `pre-commit run --all-files`; never silently narrow to staged files. When hooks fail: 1. Name the hook and the specific cause. Be concrete — "gitleaks blocked `config.json` (high-entropy string on line 12)", not "gitleaks failed". -2. Suggest one concrete next step. If the cause is not obvious from the output, read `references/failure-patterns.md`. +2. Suggest one concrete next step. Common causes and their concrete fixes are in `references/failure-patterns.md` — read it whenever the output does not already name the fix. 3. Do not auto-fix code files, and do not edit `.pre-commit-config.yaml` — those belong to the user or to `pc-author`. diff --git a/plugins/git/.apm/skills/pc-run/references/failure-patterns.md b/plugins/git/.apm/skills/pc-run/references/failure-patterns.md index bfb3c65..8931ebf 100644 --- a/plugins/git/.apm/skills/pc-run/references/failure-patterns.md +++ b/plugins/git/.apm/skills/pc-run/references/failure-patterns.md @@ -89,13 +89,14 @@ Fix: The user (or `pc-author`) must add `args: [--autofix]` to the hook override Cause: A hook's cached environment is corrupted or out of date. -Fix: +Fix: `pre-commit clean` is gated. It wipes the machine-wide cache at `~/.cache/pre-commit`, shared by every repo on the box, so get explicit confirmation before running it — "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + ```bash -pre-commit clean # wipe all environments +pre-commit clean # gated — confirm with the user first pre-commit install-hooks # rebuild everything ``` -Or less destructively: +Or less destructively, needing no confirmation: ```bash pre-commit gc # remove only unused environments ``` diff --git a/plugins/git/skills/pc-run/SKILL.md b/plugins/git/skills/pc-run/SKILL.md index e1da3a3..719b1ab 100644 --- a/plugins/git/skills/pc-run/SKILL.md +++ b/plugins/git/skills/pc-run/SKILL.md @@ -20,6 +20,15 @@ allowed-tools: Bash Read - The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. - Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. +- `- files were modified by this hook` is not a bug. A fixer hook (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json --autofix`) rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. The fix is to re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — that flag overwrites hook files in `.git/hooks/` and has nothing to do with re-staging. + +## Gate — `pre-commit clean` + +Confirm with the user before running `pre-commit clean`, on every path that reaches it — including when it turns up as the fix for a stale or broken environment. It wipes the whole cache at `~/.cache/pre-commit`, which is machine-wide and shared by every repo on the box, forcing every hook environment to be re-downloaded. + +> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + +`pre-commit gc` drops only unused environments and needs no confirmation — prefer it when the goal is just to reclaim disk. ## Route @@ -36,7 +45,7 @@ Determine intent from the user's request, then execute the matching operation. W | "autoupdate", "update versions", "bump revs" | `pre-commit autoupdate` — read `references/autoupdate.md` | | "gc", "garbage collect" | `pre-commit gc` — drops unused cached environments only, safe at any time | | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | -| "hooks aren't running", "hook never fires", "why did a hook fail" | Diagnose — read `references/failure-patterns.md` | +| "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | If the intent is ambiguous, default to `pre-commit run --all-files`. @@ -47,5 +56,5 @@ Default to `pre-commit run --all-files`; never silently narrow to staged files. When hooks fail: 1. Name the hook and the specific cause. Be concrete — "gitleaks blocked `config.json` (high-entropy string on line 12)", not "gitleaks failed". -2. Suggest one concrete next step. If the cause is not obvious from the output, read `references/failure-patterns.md`. +2. Suggest one concrete next step. Common causes and their concrete fixes are in `references/failure-patterns.md` — read it whenever the output does not already name the fix. 3. Do not auto-fix code files, and do not edit `.pre-commit-config.yaml` — those belong to the user or to `pc-author`. diff --git a/plugins/git/skills/pc-run/references/failure-patterns.md b/plugins/git/skills/pc-run/references/failure-patterns.md index bfb3c65..8931ebf 100644 --- a/plugins/git/skills/pc-run/references/failure-patterns.md +++ b/plugins/git/skills/pc-run/references/failure-patterns.md @@ -89,13 +89,14 @@ Fix: The user (or `pc-author`) must add `args: [--autofix]` to the hook override Cause: A hook's cached environment is corrupted or out of date. -Fix: +Fix: `pre-commit clean` is gated. It wipes the machine-wide cache at `~/.cache/pre-commit`, shared by every repo on the box, so get explicit confirmation before running it — "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" + ```bash -pre-commit clean # wipe all environments +pre-commit clean # gated — confirm with the user first pre-commit install-hooks # rebuild everything ``` -Or less destructively: +Or less destructively, needing no confirmation: ```bash pre-commit gc # remove only unused environments ``` -- 2.43.0 From c5b207aee8d6d25fb2d9a0a9b81b11a7f26bbf8c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:50:51 +0000 Subject: [PATCH 50/89] fix(git-branches): restore merging.md's provenance back-reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit references/sources.md credits atlassian-gitflow-tutorial with contributing the `--no-ff` requirement on Gitflow supporting-branch merges to references/merging.md, and merging.md:14 does carry that claim — but the file's own source_keys listed only context7-git-htmldocs, so the chain was one-directional. Surfaced by repairing validate-provenance.sh's Contributing-files parser in the same series; this skill was one of seven whose sources.md the old parser could not read, so checks 4 and 5 had never run against it. Refs #99 --- plugins/git/.apm/skills/git-branches/references/merging.md | 1 + plugins/git/skills/git-branches/references/merging.md | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/git/.apm/skills/git-branches/references/merging.md b/plugins/git/.apm/skills/git-branches/references/merging.md index 0c8f245..d1721e3 100644 --- a/plugins/git/.apm/skills/git-branches/references/merging.md +++ b/plugins/git/.apm/skills/git-branches/references/merging.md @@ -1,6 +1,7 @@ --- source_keys: - context7-git-htmldocs + - atlassian-gitflow-tutorial --- # Merging one branch into another diff --git a/plugins/git/skills/git-branches/references/merging.md b/plugins/git/skills/git-branches/references/merging.md index 0c8f245..d1721e3 100644 --- a/plugins/git/skills/git-branches/references/merging.md +++ b/plugins/git/skills/git-branches/references/merging.md @@ -1,6 +1,7 @@ --- source_keys: - context7-git-htmldocs + - atlassian-gitflow-tutorial --- # Merging one branch into another -- 2.43.0 From dd1981db80743607281459782f9cb6d625d1bdd1 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:02 +0000 Subject: [PATCH 51/89] fix(gitea-issues): list_issues does have type and milestones on v1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md's headline Gotcha said `list_issues` "has no `type` filter", and references/issues.md stated in bold that neither `type` nor `milestones` exists, "despite both appearing in api-reference.md". Both parameters are present on the deployed gitea-mcp v1.7.0 and both work: `type: "issues"` returns only issues, `type: "pulls"` only PRs, and `milestones` filters by name. Unfiltered, the same window returns them interleaved, so the mixing the Gotcha describes is real — only the stated remedy was wrong. This mattered most in gitea-workflow's no-args check-in, which lists open issues through this skill and so reported PRs under "Open Issues" while the skill forbade the one-parameter fix. The list flow now passes `type: "issues"`. references/sources.md recorded the absence as a live-verification win over stale research docs; it now records that the earlier check was superseded by v1.7.0, since drift runs in both directions. gitea-prs cited the same parameter as its canonical drift example and no longer does — no replacement example was substituted, because the obvious candidate was not verified in this pass. Also defaults label writes to `add_labels`: `replace_labels` clears every label not in the array, and per-label exclusivity makes blanket replacement destructive for a non-exclusive scope. Verified live against gitea-mcp v1.7.0, read-only calls. Refs #99 --- .../gitea/.apm/skills/gitea-issues/SKILL.md | 4 +-- .../skills/gitea-issues/references/issues.md | 31 +++++++++++++------ .../skills/gitea-issues/references/search.md | 4 +-- .../skills/gitea-issues/references/sources.md | 13 ++++---- .../gitea-prs/references/pull-requests.md | 2 +- plugins/gitea/skills/gitea-issues/SKILL.md | 4 +-- .../skills/gitea-issues/references/issues.md | 31 +++++++++++++------ .../skills/gitea-issues/references/search.md | 4 +-- .../skills/gitea-issues/references/sources.md | 13 ++++---- .../gitea-prs/references/pull-requests.md | 2 +- 10 files changed, 68 insertions(+), 40 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md index b168d4a..898805e 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md @@ -25,7 +25,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` returns PRs too.** It has no `type` filter and both share one repo number space. `is_pull` appears only on `issue_read method: "get"`, never on a list item — check it there before treating a number as an issue. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`, never on a list item. - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. - **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. @@ -46,7 +46,7 @@ One invocation takes one row. Read only the reference(s) that row names — the | Invocation | Flow | Read | |---|---|---| -| `/gitea-issues` or `/gitea-issues list` | List issues, optionally filtered by state | `references/issues.md` | +| `/gitea-issues` or `/gitea-issues list` | List issues with `type: "issues"` so PRs are excluded, optionally filtered by state | `references/issues.md` | | `/gitea-issues <N>` | Get one issue, routing to `gitea-prs` when the number turns out to be a PR | `references/issues.md` | | `/gitea-issues <N> comments` | Get an issue's comments | `references/issues.md` | | `/gitea-issues <N> labels` | Get an issue's labels as full objects, IDs included | `references/issues.md` | diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/issues.md b/plugins/gitea/.apm/skills/gitea-issues/references/issues.md index 41cc259..d1b0c92 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/issues.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/issues.md @@ -7,11 +7,13 @@ source_keys: # Issue operations -Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch` -at authoring time, not copied from `api-reference.md` — this is deliberate: research docs are -generated from source at a point in time and can drift from the server actually deployed (see the -`list_issues` gotcha below, which is the exact drift this policy exists to catch). Re-verify against -the live schema if these tools appear to behave differently than documented here. +Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, +not copied from `api-reference.md` — this is deliberate: research docs are generated from source at +a point in time and can drift from the server actually deployed. Last verified against gitea-mcp +**v1.7.0**, as reported by `get_gitea_mcp_server_version`. Drift runs in both directions: this file +previously recorded `list_issues` as having neither a `type` nor a `milestones` parameter, and +v1.7.0 has both. Re-verify against the live schema if these tools appear to behave differently than +documented here. ## `list_issues` @@ -21,18 +23,22 @@ the live schema if these tools appear to behave differently than documented here - `state` (string, optional, default `"all"`) — conventional values `"open"`/`"closed"`/`"all"`, not schema-enforced as an enum - `labels` (array of strings, optional) — filter by label *name* (not ID) +- `milestones` (array of strings, optional) — filter by milestone name or numeric ID, both passed as + strings +- `type` (string, enum `"issues"` | `"pulls"`, optional) — omit it and the response mixes both - `since` (string, optional) — ISO 8601, issues updated after this time - `before` (string, optional) — ISO 8601, issues updated before this time - `page` (number, optional, default `1`) - `per_page` (number, optional, default `30`) -**There is no `type` parameter and no `milestones` parameter**, despite both appearing in -`api-reference.md`. This tool cannot filter issues-vs-PRs or by milestone — see the Gotchas section -of SKILL.md for the consequence (PR entries can appear in results with no way to exclude them here). +**Pass `type: "issues"` on any listing meant to show issues.** Issues and PRs share one repo number +space and the unfiltered response interleaves them; the only thing distinguishing them on a list +item is the `html_url` path segment (`/issues/` vs `/pulls/`), since `is_pull` is not returned on +list items — see the Gotchas section of SKILL.md. **Call:** ``` -list_issues owner: <owner> repo: <repo> state: "open" +list_issues owner: <owner> repo: <repo> state: "open" type: "issues" ``` **Response (list item):** `number`, `title`, `state`, `html_url`, `user`, `comments`, `created_at`, @@ -117,6 +123,13 @@ issue_write method: "add_labels" owner: <owner> repo: <repo> issue_number: <N> l To replace all labels atomically instead of adding: `method: "replace_labels"`. To remove one: `method: "remove_label" label_id: <single ID>`. +**Default to `add_labels`.** `replace_labels` clears every label not in the array, so it drops +labels the caller never mentioned. Reach for it only when the caller asked for the issue's label +set to become exactly what they listed. In particular, do not use it to enforce one-label-per-scope: +exclusivity is a per-label property — the server drops the sibling itself for a label whose +`exclusive` field is `true`, and a label whose `exclusive` is `false` (every `Kind/*` on this +instance) is legitimately stackable. See `gitea-labels-milestones` for how to read that field. + ## Token scope All of `list_issues`, `issue_read`, and `issue_write` are verified working under a token holding diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/search.md b/plugins/gitea/.apm/skills/gitea-issues/references/search.md index 1a449b4..13b3714 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/search.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/search.md @@ -13,8 +13,8 @@ time (see `references/sources.md`) — confirmed to match `api-reference.md`. **Parameters:** - `query` (string, required) — the only hard-required parameter - `state` (string, enum `"open"` | `"closed"` | `"all"`, optional) -- `type` (string, enum `"issues"` | `"pulls"`, optional) — **this tool has a working type filter**, - unlike `list_issues` (see `references/issues.md`) +- `type` (string, enum `"issues"` | `"pulls"`, optional) — the same filter, with the same values, + that `list_issues` takes (see `references/issues.md`) - `labels` (string, optional) — comma-separated label **names** — a plain string, not the array form `list_issues` uses - `owner` (string, optional) — restrict results to one owner diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/sources.md b/plugins/gitea/.apm/skills/gitea-issues/references/sources.md index 904be31..7b60d71 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/sources.md @@ -1,12 +1,13 @@ # Sources **Note on call signatures:** per `docs/adr/0011-gitea-skill-deep-modules.md`, the tool parameter -signatures in `references/issues.md` and `references/search.md` were re-verified live via -`ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied -verbatim from `api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a -prior skill was authored from API docs that had drifted from the actual MCP tool schema; the live -check caught exactly this drift on `list_issues` (see `references/issues.md` — the research doc -documents a `type` and a `milestones` parameter that do not exist on the deployed server). +signatures in `references/issues.md` and `references/search.md` are re-verified live via +`ToolSearch` against the deployed `gitea-mcp` server — they are not copied verbatim from +`api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a prior skill was +authored from API docs that had drifted from the actual MCP tool schema. That re-verification is +ongoing, not one-off: an earlier live check recorded `list_issues` as lacking the `type` and +`milestones` parameters `api-reference.md` documents, and both are present on the deployed +gitea-mcp **v1.7.0**, which is the version these signatures are current as of. ## gitea-mcp-repo diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md index 7f92032..04a6e2c 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md @@ -7,7 +7,7 @@ source_keys: # Pull request read/write execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`). These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server. These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. ## `list_pull_requests` diff --git a/plugins/gitea/skills/gitea-issues/SKILL.md b/plugins/gitea/skills/gitea-issues/SKILL.md index b168d4a..898805e 100644 --- a/plugins/gitea/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/skills/gitea-issues/SKILL.md @@ -25,7 +25,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` returns PRs too.** It has no `type` filter and both share one repo number space. `is_pull` appears only on `issue_read method: "get"`, never on a list item — check it there before treating a number as an issue. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`, never on a list item. - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. - **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. @@ -46,7 +46,7 @@ One invocation takes one row. Read only the reference(s) that row names — the | Invocation | Flow | Read | |---|---|---| -| `/gitea-issues` or `/gitea-issues list` | List issues, optionally filtered by state | `references/issues.md` | +| `/gitea-issues` or `/gitea-issues list` | List issues with `type: "issues"` so PRs are excluded, optionally filtered by state | `references/issues.md` | | `/gitea-issues <N>` | Get one issue, routing to `gitea-prs` when the number turns out to be a PR | `references/issues.md` | | `/gitea-issues <N> comments` | Get an issue's comments | `references/issues.md` | | `/gitea-issues <N> labels` | Get an issue's labels as full objects, IDs included | `references/issues.md` | diff --git a/plugins/gitea/skills/gitea-issues/references/issues.md b/plugins/gitea/skills/gitea-issues/references/issues.md index 41cc259..d1b0c92 100644 --- a/plugins/gitea/skills/gitea-issues/references/issues.md +++ b/plugins/gitea/skills/gitea-issues/references/issues.md @@ -7,11 +7,13 @@ source_keys: # Issue operations -Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch` -at authoring time, not copied from `api-reference.md` — this is deliberate: research docs are -generated from source at a point in time and can drift from the server actually deployed (see the -`list_issues` gotcha below, which is the exact drift this policy exists to catch). Re-verify against -the live schema if these tools appear to behave differently than documented here. +Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, +not copied from `api-reference.md` — this is deliberate: research docs are generated from source at +a point in time and can drift from the server actually deployed. Last verified against gitea-mcp +**v1.7.0**, as reported by `get_gitea_mcp_server_version`. Drift runs in both directions: this file +previously recorded `list_issues` as having neither a `type` nor a `milestones` parameter, and +v1.7.0 has both. Re-verify against the live schema if these tools appear to behave differently than +documented here. ## `list_issues` @@ -21,18 +23,22 @@ the live schema if these tools appear to behave differently than documented here - `state` (string, optional, default `"all"`) — conventional values `"open"`/`"closed"`/`"all"`, not schema-enforced as an enum - `labels` (array of strings, optional) — filter by label *name* (not ID) +- `milestones` (array of strings, optional) — filter by milestone name or numeric ID, both passed as + strings +- `type` (string, enum `"issues"` | `"pulls"`, optional) — omit it and the response mixes both - `since` (string, optional) — ISO 8601, issues updated after this time - `before` (string, optional) — ISO 8601, issues updated before this time - `page` (number, optional, default `1`) - `per_page` (number, optional, default `30`) -**There is no `type` parameter and no `milestones` parameter**, despite both appearing in -`api-reference.md`. This tool cannot filter issues-vs-PRs or by milestone — see the Gotchas section -of SKILL.md for the consequence (PR entries can appear in results with no way to exclude them here). +**Pass `type: "issues"` on any listing meant to show issues.** Issues and PRs share one repo number +space and the unfiltered response interleaves them; the only thing distinguishing them on a list +item is the `html_url` path segment (`/issues/` vs `/pulls/`), since `is_pull` is not returned on +list items — see the Gotchas section of SKILL.md. **Call:** ``` -list_issues owner: <owner> repo: <repo> state: "open" +list_issues owner: <owner> repo: <repo> state: "open" type: "issues" ``` **Response (list item):** `number`, `title`, `state`, `html_url`, `user`, `comments`, `created_at`, @@ -117,6 +123,13 @@ issue_write method: "add_labels" owner: <owner> repo: <repo> issue_number: <N> l To replace all labels atomically instead of adding: `method: "replace_labels"`. To remove one: `method: "remove_label" label_id: <single ID>`. +**Default to `add_labels`.** `replace_labels` clears every label not in the array, so it drops +labels the caller never mentioned. Reach for it only when the caller asked for the issue's label +set to become exactly what they listed. In particular, do not use it to enforce one-label-per-scope: +exclusivity is a per-label property — the server drops the sibling itself for a label whose +`exclusive` field is `true`, and a label whose `exclusive` is `false` (every `Kind/*` on this +instance) is legitimately stackable. See `gitea-labels-milestones` for how to read that field. + ## Token scope All of `list_issues`, `issue_read`, and `issue_write` are verified working under a token holding diff --git a/plugins/gitea/skills/gitea-issues/references/search.md b/plugins/gitea/skills/gitea-issues/references/search.md index 1a449b4..13b3714 100644 --- a/plugins/gitea/skills/gitea-issues/references/search.md +++ b/plugins/gitea/skills/gitea-issues/references/search.md @@ -13,8 +13,8 @@ time (see `references/sources.md`) — confirmed to match `api-reference.md`. **Parameters:** - `query` (string, required) — the only hard-required parameter - `state` (string, enum `"open"` | `"closed"` | `"all"`, optional) -- `type` (string, enum `"issues"` | `"pulls"`, optional) — **this tool has a working type filter**, - unlike `list_issues` (see `references/issues.md`) +- `type` (string, enum `"issues"` | `"pulls"`, optional) — the same filter, with the same values, + that `list_issues` takes (see `references/issues.md`) - `labels` (string, optional) — comma-separated label **names** — a plain string, not the array form `list_issues` uses - `owner` (string, optional) — restrict results to one owner diff --git a/plugins/gitea/skills/gitea-issues/references/sources.md b/plugins/gitea/skills/gitea-issues/references/sources.md index 904be31..7b60d71 100644 --- a/plugins/gitea/skills/gitea-issues/references/sources.md +++ b/plugins/gitea/skills/gitea-issues/references/sources.md @@ -1,12 +1,13 @@ # Sources **Note on call signatures:** per `docs/adr/0011-gitea-skill-deep-modules.md`, the tool parameter -signatures in `references/issues.md` and `references/search.md` were re-verified live via -`ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied -verbatim from `api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a -prior skill was authored from API docs that had drifted from the actual MCP tool schema; the live -check caught exactly this drift on `list_issues` (see `references/issues.md` — the research doc -documents a `type` and a `milestones` parameter that do not exist on the deployed server). +signatures in `references/issues.md` and `references/search.md` are re-verified live via +`ToolSearch` against the deployed `gitea-mcp` server — they are not copied verbatim from +`api-reference.md`. This resolves issue #6 comment #849's root-cause finding that a prior skill was +authored from API docs that had drifted from the actual MCP tool schema. That re-verification is +ongoing, not one-off: an earlier live check recorded `list_issues` as lacking the `type` and +`milestones` parameters `api-reference.md` documents, and both are present on the deployed +gitea-mcp **v1.7.0**, which is the version these signatures are current as of. ## gitea-mcp-repo diff --git a/plugins/gitea/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/skills/gitea-prs/references/pull-requests.md index 7f92032..04a6e2c 100644 --- a/plugins/gitea/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/skills/gitea-prs/references/pull-requests.md @@ -7,7 +7,7 @@ source_keys: # Pull request read/write execution detail -Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server (e.g. a prior `type` parameter that no longer exists on `list_issues`). These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. +Parameter signatures below are cross-checked live against the deployed gitea-mcp server tool schemas at authoring time — not copied verbatim from the plugin's research doc for this domain, which has a known history of drifting from the deployed server. These files were last verified against gitea-mcp **v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify via `ToolSearch` before trusting this file if the deployed version differs — drift has bitten this skill in both directions, adding methods it does not list and fixing quirks it still warns about. ## `list_pull_requests` -- 2.43.0 From 8982ac58b742f4535d602c2b1ea4fb458d0a0746 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:12 +0000 Subject: [PATCH 52/89] fix(gitea-labels-milestones): read exclusive per label, never infer it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md called `exclusive` "an org-labels-only flag" and concluded that applying a Kind/*, Priority/* or Status/* label "must replace the one already there, not stack on it". Live `list_repo_labels` on this repo returns `exclusive` on every REPO label: all seven Kind/* plus Compat/Breaking are false, while Priority/*, Reviewed/* and Status/* are true. So the field is not org-only, and the replace rule would strip a valid Kind/* label — a destructive write from a false premise. The nuance kept: label_write's `exclusive` parameter genuinely is annotated org-only, so that row was schema-accurate. The error was generalising a write-parameter restriction into a claim about where the field exists. The row is qualified rather than deleted. The rule is now per-label: where exclusive is true the server drops the sibling itself, so do not pre-remove; where it is false the label is legitimately stackable. Also fixes the org-label fallback, which treated any list_org_labels failure as proof the owner is a user account with no org pool and said so was "an answer, not an error to report". Under the token scopes this skill declares the call fails with required=[read:organization] before any org-vs-user determination is made, so a capability gap was being reported as an absent label. Scope errors are now distinguished and reported. Verified live against gitea-mcp v1.7.0, read-only calls. Refs #99 --- .../skills/gitea-labels-milestones/SKILL.md | 6 ++- .../references/label-inference.md | 49 +++++++++++-------- .../references/labels.md | 19 +++++-- .../skills/gitea-labels-milestones/SKILL.md | 6 ++- .../references/label-inference.md | 49 +++++++++++-------- .../references/labels.md | 19 +++++-- 6 files changed, 94 insertions(+), 54 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md index 67fd8d8..c6f505e 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. -- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. +- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. ## Step 1 — Resolve owner, repo and org @@ -36,7 +36,9 @@ git remote get-url origin If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." -The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. +The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. + +Read the failure text before interpreting it. `list_org_labels` needs the `read:organization` token scope, which this skill's declared scopes (`write:issue`, `write:repository`) do not carry, so it fails with `token does not have at least one of required scope(s), required=[read:organization]` *before* it ever determines org-vs-user. Report that: the org pool went unchecked, not empty. Only a not-found response is evidence the owner is a user account with no org pool. ## Step 2 — Dispatch diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md index 0f7ddab..5a3e96f 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md @@ -12,19 +12,24 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy. `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user asks to label something without naming exact labels. -## Scoped labels are mutually exclusive — replace, don't stack +## Exclusivity is per label — read it, never infer it -Each of `Kind/*`, `Priority/*`, `Status/*` is treated as a scoped-label group by convention (the `/` -delimiter naming pattern). Gitea's `exclusive` flag — the mechanism that would let the server itself -enforce one-label-per-scope — is documented as an org-labels-only setting, and the repo-level -`label_write` methods used here don't accept it at all. So exclusivity within these scopes is a -convention this skill enforces client-side, not something the server guarantees: applying a new -label within a scope is expected to replace any existing label in that same scope on the target -issue/PR, not add alongside it. When inference -selects a `Priority/High` label and the issue already carries `Priority/Medium`, the write should -result in only `Priority/High` remaining — use `replace_labels` scoped to that group's labels, or at -minimum remove the superseded label before adding the new one. Never leave two labels from the same -scope applied at once. +Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is +`true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/` +delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`, +`Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and +`Compat/Breaking` — is `exclusive: false` and is used stacked. + +So read each candidate label's own `exclusive` value from the resolution call and branch on it: + +- **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not + pre-remove the label already there, and do not compute a replacement set client-side. Inferring + `Priority/High` onto an issue carrying `Priority/Medium` needs no special handling. +- **`exclusive: false`** — **add alongside, never replace.** Stripping a co-existing label in the + same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security` + at once. + +There is no client-side exclusivity convention for this skill to enforce. ## Signal → label mapping @@ -57,16 +62,18 @@ scope applied at once. 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the signals above. 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current - label set with IDs — inference must never guess an ID, only a name, then resolve it. Because - `exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any - inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with - `org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner - is a user account, not an organisation, so no org pool exists and the name is genuinely absent. -3. Match inferred label names against the resolved list (case-insensitive). If a scope group - already has a different label applied on the target and a new one is inferred for that same - scope, plan to replace rather than add (see above). + label set with IDs and each label's `exclusive` value — inference must never guess an ID, only a + name, then resolve it. Both pools can apply to one issue, so for any inferred name absent from + the repo pool, also call `label_read method: "list_org_labels"` with `org` set to the repo's + `owner` before treating it as unresolved. Read that call's failure text: a + `required=[read:organization]` scope error means the org pool was never queried — report the + missing token scope rather than reporting the label unresolved. Only a not-found response means + there is no org pool and the name is genuinely absent. +3. Match inferred label names against the resolved list (case-insensitive) and carry each match's + `exclusive` value forward: `true` means the server replaces the sibling on write, `false` means + the label is added alongside whatever is already applied (see above). 4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value, do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one exception: it's the explicit default when no urgency signal is present, not a guess. -5. Hand the resolved IDs (plus which scopes to replace) to the caller's `issue_write`/ +5. Hand the resolved IDs, each with its `exclusive` value, to the caller's `issue_write`/ `pull_request_write` call — this skill does not apply labels to an issue or PR itself. diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md index 63939f4..f6e9b40 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md @@ -37,7 +37,7 @@ or **org-scoped** labels — never both in one call. Pick the method family (`*_ | `name` | string | required for create | | `color` | string | hex `#RRGGBB`, required for create | | `description` | string | optional | -| `exclusive` | boolean | org labels only | +| `exclusive` | boolean | accepted as a *write* param on org creates only — repo labels still carry and enforce `exclusive`, set outside this tool surface | | `is_archived` | boolean | repo labels only | Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on @@ -53,6 +53,12 @@ label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page: Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way to build a complete name → ID map — there is no lookup-by-name endpoint. +Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on +`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`, +while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea +enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately. +Read the field — never infer exclusivity from the `/` in a name. + ## Get one label ``` @@ -65,8 +71,10 @@ There is no direct name lookup. List all repo labels (paginating if needed), sca case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the -owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and -the miss is a real miss. +owner is an organisation. Its failure modes are not interchangeable. `token does not have at least +one of required scope(s), required=[read:organization]` means the org pool was never queried — report +that missing scope rather than reporting the label unresolved. Only a not-found response means the +owner is a user account with no org pool, making the miss a real miss. Resolution is the required first step before any label application on an issue or PR — the actual `add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via @@ -83,7 +91,10 @@ label_write method: "create_repo_label" ``` For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and -`exclusive: true` if the label belongs to a mutually-exclusive scope group. +`exclusive: true` if the label belongs to a mutually-exclusive scope group. `label_write` accepts +`exclusive` on org methods only, so a repo label's exclusivity cannot be set or cleared through this +tool — it is set in the Gitea UI or against the REST API directly, and read back via +`list_repo_labels`. ## Edit a label diff --git a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md index 67fd8d8..c6f505e 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. -- **`Kind/*`/`Priority/*`/`Status/*` exclusivity is a client-side convention.** `exclusive` is an org-labels-only flag, so applying a label in such a scope must replace the one already there, not stack on it. +- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. ## Step 1 — Resolve owner, repo and org @@ -36,7 +36,9 @@ git remote get-url origin If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." -The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. So let the call itself decide: a failure means the owner is a user account with no org label pool, which is an answer, not an error to report. +The `*_org_label*` methods take `org`, not `owner`/`repo`. Pass that same `owner` as `org` — it is the org name whenever the owner is an organisation, and the remote URL does not say whether it is one. + +Read the failure text before interpreting it. `list_org_labels` needs the `read:organization` token scope, which this skill's declared scopes (`write:issue`, `write:repository`) do not carry, so it fails with `token does not have at least one of required scope(s), required=[read:organization]` *before* it ever determines org-vs-user. Report that: the org pool went unchecked, not empty. Only a not-found response is evidence the owner is a user account with no org pool. ## Step 2 — Dispatch diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md index 0f7ddab..5a3e96f 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md @@ -12,19 +12,24 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy. `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user asks to label something without naming exact labels. -## Scoped labels are mutually exclusive — replace, don't stack +## Exclusivity is per label — read it, never infer it -Each of `Kind/*`, `Priority/*`, `Status/*` is treated as a scoped-label group by convention (the `/` -delimiter naming pattern). Gitea's `exclusive` flag — the mechanism that would let the server itself -enforce one-label-per-scope — is documented as an org-labels-only setting, and the repo-level -`label_write` methods used here don't accept it at all. So exclusivity within these scopes is a -convention this skill enforces client-side, not something the server guarantees: applying a new -label within a scope is expected to replace any existing label in that same scope on the target -issue/PR, not add alongside it. When inference -selects a `Priority/High` label and the issue already carries `Priority/Medium`, the write should -result in only `Priority/High` remaining — use `replace_labels` scoped to that group's labels, or at -minimum remove the superseded label before adding the new one. Never leave two labels from the same -scope applied at once. +Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is +`true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/` +delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`, +`Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and +`Compat/Breaking` — is `exclusive: false` and is used stacked. + +So read each candidate label's own `exclusive` value from the resolution call and branch on it: + +- **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not + pre-remove the label already there, and do not compute a replacement set client-side. Inferring + `Priority/High` onto an issue carrying `Priority/Medium` needs no special handling. +- **`exclusive: false`** — **add alongside, never replace.** Stripping a co-existing label in the + same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security` + at once. + +There is no client-side exclusivity convention for this skill to enforce. ## Signal → label mapping @@ -57,16 +62,18 @@ scope applied at once. 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the signals above. 2. Call `label_read method: "list_repo_labels"` (see `references/labels.md`) to get the current - label set with IDs — inference must never guess an ID, only a name, then resolve it. Because - `exclusive` is an org-labels-only flag, this taxonomy plausibly lives at org scope too: for any - inferred name absent from the repo pool, also call `label_read method: "list_org_labels"` with - `org` set to the repo's `owner` before treating it as unresolved. A failure there means the owner - is a user account, not an organisation, so no org pool exists and the name is genuinely absent. -3. Match inferred label names against the resolved list (case-insensitive). If a scope group - already has a different label applied on the target and a new one is inferred for that same - scope, plan to replace rather than add (see above). + label set with IDs and each label's `exclusive` value — inference must never guess an ID, only a + name, then resolve it. Both pools can apply to one issue, so for any inferred name absent from + the repo pool, also call `label_read method: "list_org_labels"` with `org` set to the repo's + `owner` before treating it as unresolved. Read that call's failure text: a + `required=[read:organization]` scope error means the org pool was never queried — report the + missing token scope rather than reporting the label unresolved. Only a not-found response means + there is no org pool and the name is genuinely absent. +3. Match inferred label names against the resolved list (case-insensitive) and carry each match's + `exclusive` value forward: `true` means the server replaces the sibling on write, `false` means + the label is added alongside whatever is already applied (see above). 4. **Low-confidence inference omits the label.** If no signal confidently maps to a `Kind/*` value, do not guess — omit `Kind/*` entirely rather than default to one. `Priority/Medium` is the one exception: it's the explicit default when no urgency signal is present, not a guess. -5. Hand the resolved IDs (plus which scopes to replace) to the caller's `issue_write`/ +5. Hand the resolved IDs, each with its `exclusive` value, to the caller's `issue_write`/ `pull_request_write` call — this skill does not apply labels to an issue or PR itself. diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md index 63939f4..f6e9b40 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md @@ -37,7 +37,7 @@ or **org-scoped** labels — never both in one call. Pick the method family (`*_ | `name` | string | required for create | | `color` | string | hex `#RRGGBB`, required for create | | `description` | string | optional | -| `exclusive` | boolean | org labels only | +| `exclusive` | boolean | accepted as a *write* param on org creates only — repo labels still carry and enforce `exclusive`, set outside this tool surface | | `is_archived` | boolean | repo labels only | Note: unlike `milestone_read`/`milestone_write`, `owner`/`repo`/`org` are **not** schema-required on @@ -53,6 +53,12 @@ label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page: Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way to build a complete name → ID map — there is no lookup-by-name endpoint. +Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on +`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`, +while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea +enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately. +Read the field — never infer exclusivity from the `/` in a name. + ## Get one label ``` @@ -65,8 +71,10 @@ There is no direct name lookup. List all repo labels (paginating if needed), sca case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the -owner is an organisation. If that call fails, the owner is a user account, there is no org pool, and -the miss is a real miss. +owner is an organisation. Its failure modes are not interchangeable. `token does not have at least +one of required scope(s), required=[read:organization]` means the org pool was never queried — report +that missing scope rather than reporting the label unresolved. Only a not-found response means the +owner is a user account with no org pool, making the miss a real miss. Resolution is the required first step before any label application on an issue or PR — the actual `add_labels`/`replace_labels`/`remove_label` call lives in `gitea-issues`/`gitea-prs` via @@ -83,7 +91,10 @@ label_write method: "create_repo_label" ``` For an org label, use `method: "create_org_label"` with `org:` instead of `owner`/`repo`, and -`exclusive: true` if the label belongs to a mutually-exclusive scope group. +`exclusive: true` if the label belongs to a mutually-exclusive scope group. `label_write` accepts +`exclusive` on org methods only, so a repo label's exclusivity cannot be set or cleared through this +tool — it is set in the Gitea UI or against the REST API directly, and read back via +`list_repo_labels`. ## Edit a label -- 2.43.0 From d2da45f78cae98286c07db58c4e63d5f24c57ec9 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:22 +0000 Subject: [PATCH 53/89] fix(gitea-releases): restore the prerelease imperative to the create path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retrofit left only a descriptive sentence on the loaded path — "Gitea never infers a prerelease from a -beta/-rc tag name" — and moved the imperative into references/conventions.md behind a trigger listing semver naming, release-notes sourcing and release-to-tag relationships. Draft and prerelease are not in that list, and "cut a v2.0.0-beta.1" is exactly the request where the caller does not raise the topic, so the rule was unreachable from the flow that needs it. Severity comes from the repair path: the MCP surface has create, get, get_latest, list and delete only — there is no update or edit tool. A release published without is_pre_release can only be corrected by delete_release plus a fresh create, and get_latest_release points consumers at the beta meanwhile. Neither SKILL.md nor call-signatures.md said so anywhere. The flags are now set explicitly on every create, the missing update tool and its delete-and-recreate consequence are stated in the body, and the semver pass-through rule stranded behind the same trigger is promoted alongside it. call-signatures.md now cites the deployed tool description as direct evidence that get_latest_release excludes drafts, while keeping the prerelease half hedged — that remains unconfirmed. Verified live against gitea-mcp v1.7.0, read-only calls. Refs #99 --- plugins/gitea/.apm/skills/gitea-releases/SKILL.md | 8 +++++--- .../skills/gitea-releases/references/call-signatures.md | 2 +- .../.apm/skills/gitea-releases/references/conventions.md | 7 +++++++ plugins/gitea/skills/gitea-releases/SKILL.md | 8 +++++--- .../skills/gitea-releases/references/call-signatures.md | 2 +- .../gitea/skills/gitea-releases/references/conventions.md | 7 +++++++ 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md index 37a3444..c66d7cc 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md @@ -18,7 +18,7 @@ metadata: ## Gotchas - **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. -- **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. +- **Set `is_draft`/`is_pre_release` explicitly on every `create_release` — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** A tag named `v2.0.0-beta.1` publishes as a full release, and becomes the repo's latest, unless `is_pre_release: true` is passed in the same call. The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. - **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. ## Dispatch table @@ -37,11 +37,13 @@ metadata: `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. +Pass a caller-supplied `tag_name` through verbatim. Semver with a `v` prefix is a tooling convention, not a Gitea constraint — the API accepts any string — so never validate or rewrite it. + ## Workflow -- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, and `title`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. +- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. **There is no update or edit tool on this surface**: `create_release`, `get_release`, `get_latest_release`, `list_releases` and `delete_release` are the whole set. A release published with the wrong flag therefore has no non-destructive repair — the only fix is `delete_release` plus a fresh `create_release`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. - [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. - [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. - [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. -If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. +If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, draft/prerelease semantics, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md b/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md index 62667db..49369d4 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md @@ -32,7 +32,7 @@ for brevity. **`get_latest_release`** - No parameters beyond `owner`/`repo`. -- Returns a single release object for the most recently published release. It is assumed (by analogy with typical "latest release" semantics) that this excludes drafts and prereleases, but that exclusion is not directly confirmed by any of the research docs — verify with `list_releases` if the caller depends on this. +- Returns a single release object for the most recently published release. The deployed tool's own description reads "the most recent published (non-draft) release" — it names drafts as excluded and is silent on prereleases, so whether prereleases are also excluded is *unconfirmed*; verify with `list_releases` if the caller depends on it. Either way a release created without `is_pre_release: true` is eligible, which is why that flag has to be set in the create call (see `conventions.md`). **`create_release`** - Required: `tag_name` (string), `target` (string — branch, tag, or commit SHA to cut the tag from), `title` (string) diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md b/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md index b8cf75f..eabeeec 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md @@ -33,6 +33,13 @@ commonly used to signal a prerelease to humans. When a user asks to "cut a beta" release candidate," set `is_pre_release: true` explicitly in the same call rather than relying on the tag string to carry that meaning. +Getting this wrong is not cheaply repairable. The gitea-mcp release surface is `create_release`, +`get_release`, `get_latest_release`, `list_releases` and `delete_release` — there is **no update or +edit tool**, so a published release's flags cannot be corrected in place. The only remedy is +`delete_release` plus a fresh `create_release`, a destructive round trip; meanwhile a beta published +without `is_pre_release` is the release `get_latest_release` returns. Set the flags in the create +call. + Note the input/output naming mismatch: the input param is `is_draft`, but the release object returned by the API uses `draft` (and `prerelease`) as the field names. `draft` is never a valid input key — passing `draft: true` to `create_release` is silently ignored rather than erroring. diff --git a/plugins/gitea/skills/gitea-releases/SKILL.md b/plugins/gitea/skills/gitea-releases/SKILL.md index 37a3444..c66d7cc 100644 --- a/plugins/gitea/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/skills/gitea-releases/SKILL.md @@ -18,7 +18,7 @@ metadata: ## Gotchas - **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. -- **`is_draft`/`is_pre_release` are booleans the caller sets — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. +- **Set `is_draft`/`is_pre_release` explicitly on every `create_release` — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** A tag named `v2.0.0-beta.1` publishes as a full release, and becomes the repo's latest, unless `is_pre_release: true` is passed in the same call. The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. - **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. ## Dispatch table @@ -37,11 +37,13 @@ metadata: `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. +Pass a caller-supplied `tag_name` through verbatim. Semver with a `v` prefix is a tooling convention, not a Gitea constraint — the API accepts any string — so never validate or rewrite it. + ## Workflow -- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, and `title`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. +- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. **There is no update or edit tool on this surface**: `create_release`, `get_release`, `get_latest_release`, `list_releases` and `delete_release` are the whole set. A release published with the wrong flag therefore has no non-destructive repair — the only fix is `delete_release` plus a fresh `create_release`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. - [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. - [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. - [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. -If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. +If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, draft/prerelease semantics, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/skills/gitea-releases/references/call-signatures.md b/plugins/gitea/skills/gitea-releases/references/call-signatures.md index 62667db..49369d4 100644 --- a/plugins/gitea/skills/gitea-releases/references/call-signatures.md +++ b/plugins/gitea/skills/gitea-releases/references/call-signatures.md @@ -32,7 +32,7 @@ for brevity. **`get_latest_release`** - No parameters beyond `owner`/`repo`. -- Returns a single release object for the most recently published release. It is assumed (by analogy with typical "latest release" semantics) that this excludes drafts and prereleases, but that exclusion is not directly confirmed by any of the research docs — verify with `list_releases` if the caller depends on this. +- Returns a single release object for the most recently published release. The deployed tool's own description reads "the most recent published (non-draft) release" — it names drafts as excluded and is silent on prereleases, so whether prereleases are also excluded is *unconfirmed*; verify with `list_releases` if the caller depends on it. Either way a release created without `is_pre_release: true` is eligible, which is why that flag has to be set in the create call (see `conventions.md`). **`create_release`** - Required: `tag_name` (string), `target` (string — branch, tag, or commit SHA to cut the tag from), `title` (string) diff --git a/plugins/gitea/skills/gitea-releases/references/conventions.md b/plugins/gitea/skills/gitea-releases/references/conventions.md index b8cf75f..eabeeec 100644 --- a/plugins/gitea/skills/gitea-releases/references/conventions.md +++ b/plugins/gitea/skills/gitea-releases/references/conventions.md @@ -33,6 +33,13 @@ commonly used to signal a prerelease to humans. When a user asks to "cut a beta" release candidate," set `is_pre_release: true` explicitly in the same call rather than relying on the tag string to carry that meaning. +Getting this wrong is not cheaply repairable. The gitea-mcp release surface is `create_release`, +`get_release`, `get_latest_release`, `list_releases` and `delete_release` — there is **no update or +edit tool**, so a published release's flags cannot be corrected in place. The only remedy is +`delete_release` plus a fresh `create_release`, a destructive round trip; meanwhile a beta published +without `is_pre_release` is the release `get_latest_release` returns. Set the flags in the create +call. + Note the input/output naming mismatch: the input param is `is_draft`, but the release object returned by the API uses `draft` (and `prerelease`) as the field names. `draft` is never a valid input key — passing `draft: true` to `create_release` is silently ignored rather than erroring. -- 2.43.0 From ca744d5d6cfdd59b94795d144926aad4d900d72c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:32 +0000 Subject: [PATCH 54/89] fix(vale-config): correct the missing-style failure mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gotcha 1 said a style in BasedOnStyles that is not built-in and not already under StylesPath "finds nothing until vale sync fetches it — a clean run is not proof anything linted". Reproduced against vale 3.15.2: that case is a hard `E100 [loadStyles] style '<name>' does not exist on StylesPath`, exit 2. Nothing is linted and nothing is silent. Worse, the same commit deleted the Gotcha that was the actual diagnostic — that Packages and BasedOnStyles are separate keys and a style lints only once it is in both. So the surviving rule sent a reader staring at E100 to run `vale sync`, which fetches only what Packages declares and reports "Synced 0 package(s)" against a BasedOnStyles-only name. The remediation loop did not terminate. Reproduced end to end. The genuinely silent case is the reverse — declared in Packages and synced, but absent from BasedOnStyles — and it is now the one labelled as such. Testing also turned up that StylesPath must exist as a directory even when Vale is the only style (E201, exit 2), which was documented nowhere. references/configuration-reference.md gains a seven-row resolution matrix, each row backed by a fixture. Its Frontmatter Scopes section claimed provenance from the vale.sh research corpus, which contains no frontmatter material at all; it is house-verified and now says so under its own slug. Issue #99's wave-3 comment recorded this defect as found and repaired. It was not — the file was byte-identical to the commit that introduced it, so nothing here was treated as already correct. Refs #99 --- plugins/lint/.apm/skills/vale-config/SKILL.md | 7 +++++-- .../references/configuration-reference.md | 19 ++++++++++++++++--- .../skills/vale-config/references/sources.md | 8 ++++++++ plugins/lint/skills/vale-config/SKILL.md | 7 +++++-- .../references/configuration-reference.md | 19 ++++++++++++++++--- .../skills/vale-config/references/sources.md | 8 ++++++++ 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/plugins/lint/.apm/skills/vale-config/SKILL.md b/plugins/lint/.apm/skills/vale-config/SKILL.md index be534fb..352daeb 100644 --- a/plugins/lint/.apm/skills/vale-config/SKILL.md +++ b/plugins/lint/.apm/skills/vale-config/SKILL.md @@ -11,11 +11,14 @@ metadata: version: "0.1.1" source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas -- A style in `BasedOnStyles` that is neither built-in nor already under `StylesPath` finds nothing until `vale sync` fetches it — a clean run is not proof anything linted. +- A style in `BasedOnStyles` that is neither built-in nor a directory under `StylesPath` fails hard, not silently: `E100 [loadStyles]`, exit 2, nothing linted. +- `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. +- Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. - `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. @@ -42,7 +45,7 @@ metadata: - [ ] **Sync**: run `vale sync` to download everything listed in `Packages` into `StylesPath`. - [ ] **Verify activation**: confirm every style named in `Packages` also appears in at least one glob's `BasedOnStyles` — an unreferenced package downloads but never lints anything. -For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields), read `references/configuration-reference.md`. +For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields) and the verified style-resolution matrix — which error each misconfiguration raises, and the two that exit 0 while linting nothing — read `references/configuration-reference.md`. ## Custom styles diff --git a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md index 7da0086..aec5fa7 100644 --- a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md @@ -2,6 +2,7 @@ topic: configuration-reference source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Core Settings @@ -68,7 +69,7 @@ Individual rule YAML files (under a style's directory) support these header fiel The underlying functions a rule's `extends` field can reference: `existence`, `substitution`, `occurrence`, `repetition`, `consistency`, `conditional`, `capitalization`, `metric`, `spelling`, `sequence`, `script`. -## Built-in Style +## Style Resolution Only *package* styles need fetching. A style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no `vale sync`; the same is true of the built-in `Vale` style, which ships with the binary and contains four rules: @@ -77,11 +78,23 @@ Only *package* styles need fetching. A style whose YAML rule files are already c - `Vale.Avoid` — enforces the project's rejected vocabulary terms. - `Vale.Repetition` — flags repeated words (e.g. "the the"). +`Packages` (top-level, what `vale sync` downloads) and `BasedOnStyles` (per-glob, what activates) are separate keys: a style lints a file only once it is in both. Every row below reproduced against Vale 3.15.2 (slug `house-vale-3-15-2-repro`): + +| Configuration | Result | +|---|---| +| `BasedOnStyles` names a style with no directory under `StylesPath`, not built-in | `E100 [loadStyles] Runtime error` — `style 'X' does not exist on StylesPath`, exit 2 | +| `StylesPath` directory itself absent, even with only `Vale` active | `E201 Invalid value` — `The path '...' does not exist`, exit 2 | +| `vale sync` with a name in `BasedOnStyles` but not `Packages` | `SUCCESS Synced 0 package(s)`, exit 0, nothing downloaded — the next lint repeats the `E100` | +| `vale sync` with the name added to `Packages` | package lands under `StylesPath`, exit 0; lint then loads it | +| Style in `Packages` and synced, but in no glob's `BasedOnStyles` | 0 findings, exit 0 — downloads, never lints, indistinguishable from a clean run | +| `BasedOnStyles` names an *empty* directory under `StylesPath` | 0 findings, exit 0 — loads and lints nothing; `vale sync` never produces this state | +| Built-in `Vale`, or a style's YAML committed under `StylesPath` | lints immediately, no `Packages` entry, no sync | + ## Frontmatter Scopes -A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. +House-verified behaviour, not documented on vale.sh — reproduced locally against Vale 3.15.2 (slug `house-vale-3-15-2-repro`). -Confirmed against Vale 3.15.2, multi-line forms spanning 2+ lines: +A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Multi-line forms spanning 2+ lines: | Frontmatter value form | Result | |---|---| diff --git a/plugins/lint/.apm/skills/vale-config/references/sources.md b/plugins/lint/.apm/skills/vale-config/references/sources.md index 854fe15..a983a59 100644 --- a/plugins/lint/.apm/skills/vale-config/references/sources.md +++ b/plugins/lint/.apm/skills/vale-config/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/configuration-reference.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/configuration-reference.md +- **Status:** `extracted` diff --git a/plugins/lint/skills/vale-config/SKILL.md b/plugins/lint/skills/vale-config/SKILL.md index be534fb..352daeb 100644 --- a/plugins/lint/skills/vale-config/SKILL.md +++ b/plugins/lint/skills/vale-config/SKILL.md @@ -11,11 +11,14 @@ metadata: version: "0.1.1" source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas -- A style in `BasedOnStyles` that is neither built-in nor already under `StylesPath` finds nothing until `vale sync` fetches it — a clean run is not proof anything linted. +- A style in `BasedOnStyles` that is neither built-in nor a directory under `StylesPath` fails hard, not silently: `E100 [loadStyles]`, exit 2, nothing linted. +- `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. +- Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. - `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. @@ -42,7 +45,7 @@ metadata: - [ ] **Sync**: run `vale sync` to download everything listed in `Packages` into `StylesPath`. - [ ] **Verify activation**: confirm every style named in `Packages` also appears in at least one glob's `BasedOnStyles` — an unreferenced package downloads but never lints anything. -For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields), read `references/configuration-reference.md`. +For the full `.vale.ini` field reference (formats mapping, vocab, local overrides, custom rule header fields) and the verified style-resolution matrix — which error each misconfiguration raises, and the two that exit 0 while linting nothing — read `references/configuration-reference.md`. ## Custom styles diff --git a/plugins/lint/skills/vale-config/references/configuration-reference.md b/plugins/lint/skills/vale-config/references/configuration-reference.md index 7da0086..aec5fa7 100644 --- a/plugins/lint/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/skills/vale-config/references/configuration-reference.md @@ -2,6 +2,7 @@ topic: configuration-reference source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Core Settings @@ -68,7 +69,7 @@ Individual rule YAML files (under a style's directory) support these header fiel The underlying functions a rule's `extends` field can reference: `existence`, `substitution`, `occurrence`, `repetition`, `consistency`, `conditional`, `capitalization`, `metric`, `spelling`, `sequence`, `script`. -## Built-in Style +## Style Resolution Only *package* styles need fetching. A style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no `vale sync`; the same is true of the built-in `Vale` style, which ships with the binary and contains four rules: @@ -77,11 +78,23 @@ Only *package* styles need fetching. A style whose YAML rule files are already c - `Vale.Avoid` — enforces the project's rejected vocabulary terms. - `Vale.Repetition` — flags repeated words (e.g. "the the"). +`Packages` (top-level, what `vale sync` downloads) and `BasedOnStyles` (per-glob, what activates) are separate keys: a style lints a file only once it is in both. Every row below reproduced against Vale 3.15.2 (slug `house-vale-3-15-2-repro`): + +| Configuration | Result | +|---|---| +| `BasedOnStyles` names a style with no directory under `StylesPath`, not built-in | `E100 [loadStyles] Runtime error` — `style 'X' does not exist on StylesPath`, exit 2 | +| `StylesPath` directory itself absent, even with only `Vale` active | `E201 Invalid value` — `The path '...' does not exist`, exit 2 | +| `vale sync` with a name in `BasedOnStyles` but not `Packages` | `SUCCESS Synced 0 package(s)`, exit 0, nothing downloaded — the next lint repeats the `E100` | +| `vale sync` with the name added to `Packages` | package lands under `StylesPath`, exit 0; lint then loads it | +| Style in `Packages` and synced, but in no glob's `BasedOnStyles` | 0 findings, exit 0 — downloads, never lints, indistinguishable from a clean run | +| `BasedOnStyles` names an *empty* directory under `StylesPath` | 0 findings, exit 0 — loads and lints nothing; `vale sync` never produces this state | +| Built-in `Vale`, or a style's YAML committed under `StylesPath` | lints immediately, no `Packages` entry, no sync | + ## Frontmatter Scopes -A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. +House-verified behaviour, not documented on vale.sh — reproduced locally against Vale 3.15.2 (slug `house-vale-3-15-2-repro`). -Confirmed against Vale 3.15.2, multi-line forms spanning 2+ lines: +A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Multi-line forms spanning 2+ lines: | Frontmatter value form | Result | |---|---| diff --git a/plugins/lint/skills/vale-config/references/sources.md b/plugins/lint/skills/vale-config/references/sources.md index 854fe15..a983a59 100644 --- a/plugins/lint/skills/vale-config/references/sources.md +++ b/plugins/lint/skills/vale-config/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/configuration-reference.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/configuration-reference.md +- **Status:** `extracted` -- 2.43.0 From 164948a0bca1492c79661b8ef075bc24e3042cd1 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:45 +0000 Subject: [PATCH 55/89] fix(apm-workflow): type: selects processing, it does not validate content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configure.md said apm.yml's `type:` field "constrains what .apm/ may contain" and that changing it later "does not retroactively validate what is already on disk" — both implying a validation step that does not exist. Read against the installed apm-cli 0.28.0: PackageContentType controls how a package is processed during install/compile, apm_package.py only enum-checks the declared string, and validate_apm_package() branches on the structural type derived from files on disk, never on the declared field. There is no content-vs-type mismatch check anywhere. The hazard is therefore the opposite of what the wording primed for: silent omission. A package declaring type: instructions while shipping .apm/skills/ installs no skill and compiles AGENTS.md only, exits 0, and reports success having shipped none of its primitives. The rule is now to verify deployed output rather than the exit code. apm-orchestrate carried the same wording as a Hard Rule and is corrected in step; its separate defects stay with #120. Also refreshes the exemplar figures this branch had re-staled. 264a5db set them to 3,222 words of references; 6cb47f8 then added 63 words and invalidated them, and the correction above adds more. Re-measured after all edits: body 237 and whole-file 304 both still hold, references total 3,416. body-discipline.md's "roughly 3,200" moves with it. ADR-0020 is deliberately untouched — it self-pins its citations to f9b919d — as is the git-commits negative example pinned to 5e23250. Refs #99 --- plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md | 2 +- .../.apm/skills/apm-workflow/references/configure.md | 4 ++-- .../.apm/skills/skill-audit/references/body-discipline.md | 2 +- .../.apm/skills/skill-author/references/contract.md | 2 +- plugins/kyberforge/agents/apm-orchestrate.agent.md | 2 +- .../kyberforge/skills/apm-workflow/references/configure.md | 4 ++-- .../skills/skill-audit/references/body-discipline.md | 2 +- plugins/kyberforge/skills/skill-author/references/contract.md | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md index 0e048c9..ad736b7 100644 --- a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md @@ -20,7 +20,7 @@ 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/references/marketplace.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. +- `apm.yml`'s `type:` field routes processing (native skill install vs AGENTS.md compilation); it never validates `.apm/` content, and a mismatch is silent rather than an error. When scaffolding (`init-package`), set `type:` to cover every primitive the package will ship, and report the deployed output rather than the exit code — see apm-workflow/references/configure.md Gotchas. - 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/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. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md index e99cc0e..6b7021f 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md @@ -25,7 +25,7 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains what `.apm/` may contain, so set it before scaffolding content (see Gotchas) +- `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; 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 @@ -85,6 +85,6 @@ apm config set registry.corp-main.default true ## Gotchas -- `apm.yml`'s `type:` field constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later does not retroactively validate what is already on disk. +- `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. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index d4a7945..e271508 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -81,7 +81,7 @@ table** plus the gates common to every branch, and each flow lives in its own se invocation then pays for every branch it did not take. The reference shape in this repo is `apm-workflow`: a **237-word body** dispatching to roughly -3,200 words of references across five mutually exclusive invocations. Its whole-file count is 304 +3,400 words of references across five mutually exclusive invocations. Its whole-file count is 304 words — cite 237 when calibrating a body, or the conflation this section warns against reappears in the finding itself. diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md index 70723ef..15bbab4 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md @@ -120,7 +120,7 @@ A generic pointer ("see references/ for details") is a Vale error — the agent **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 **237-word body** dispatching to 3,222 words of +file. Exemplar: the `apm-workflow` skill — a **237-word body** dispatching to 3,416 words of references. Calibrate against 237: that file's whole-file count is 304 words, and aiming at that number instead overshoots the body budget by ~30%. diff --git a/plugins/kyberforge/agents/apm-orchestrate.agent.md b/plugins/kyberforge/agents/apm-orchestrate.agent.md index 0e048c9..ad736b7 100644 --- a/plugins/kyberforge/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/agents/apm-orchestrate.agent.md @@ -20,7 +20,7 @@ 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/references/marketplace.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. +- `apm.yml`'s `type:` field routes processing (native skill install vs AGENTS.md compilation); it never validates `.apm/` content, and a mismatch is silent rather than an error. When scaffolding (`init-package`), set `type:` to cover every primitive the package will ship, and report the deployed output rather than the exit code — see apm-workflow/references/configure.md Gotchas. - 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/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. diff --git a/plugins/kyberforge/skills/apm-workflow/references/configure.md b/plugins/kyberforge/skills/apm-workflow/references/configure.md index e99cc0e..6b7021f 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/skills/apm-workflow/references/configure.md @@ -25,7 +25,7 @@ version: 1.0.0 - `name`, `version` — required (see above) - `description`, `author`, `license`, `homepage`, `repository`, `keywords` — standard package metadata -- `type` — `instructions | skill | hybrid | prompts`; constrains what `.apm/` may contain, so set it before scaffolding content (see Gotchas) +- `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; 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 @@ -85,6 +85,6 @@ apm config set registry.corp-main.default true ## Gotchas -- `apm.yml`'s `type:` field constrains what `.apm/` may contain — set it before scaffolding content, not after. Changing it later does not retroactively validate what is already on disk. +- `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. diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index d4a7945..e271508 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -81,7 +81,7 @@ table** plus the gates common to every branch, and each flow lives in its own se invocation then pays for every branch it did not take. The reference shape in this repo is `apm-workflow`: a **237-word body** dispatching to roughly -3,200 words of references across five mutually exclusive invocations. Its whole-file count is 304 +3,400 words of references across five mutually exclusive invocations. Its whole-file count is 304 words — cite 237 when calibrating a body, or the conflation this section warns against reappears in the finding itself. diff --git a/plugins/kyberforge/skills/skill-author/references/contract.md b/plugins/kyberforge/skills/skill-author/references/contract.md index 70723ef..15bbab4 100644 --- a/plugins/kyberforge/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/skills/skill-author/references/contract.md @@ -120,7 +120,7 @@ A generic pointer ("see references/ for details") is a Vale error — the agent **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 **237-word body** dispatching to 3,222 words of +file. Exemplar: the `apm-workflow` skill — a **237-word body** dispatching to 3,416 words of references. Calibrate against 237: that file's whole-file count is 304 words, and aiming at that number instead overshoots the body budget by ~30%. -- 2.43.0 From 484357a3b9d49f5be6b8b186b055b4cd2fbd19f9 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:51:56 +0000 Subject: [PATCH 56/89] fix(gates): parse the bullet form of Contributing files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-provenance.sh matched Contributing files only as a single inline line beginning "- **Contributing files:**". Seven skills write it as a bare "**Contributing files:**" heading above a bullet list, so parse_contributing_files returned None and checks 4 (contributing file exists) and 5 (bidirectional source_keys) silently verified nothing on git-branches, git-remotes, git-submodules, git-workflow, git-worktrees, gitea-files and gitea-releases. Those are among the skills this branch changed most — git-branches alone gained five reference files — and the retrofit's mandatory sources.md collateral went in unchecked. Demonstrated rather than argued: planting a nonexistent contributing path in git-remotes yields 0 findings under the old parser and 1 FAIL under the new one. Both forms are now accepted. The bullet form is parsed per bullet rather than by splitting a joined value, because its per-file notes contain commas that would otherwise be read as path separators. The return type becomes a list of note-stripped paths, with "(none)" as an empty list and an absent entry as None, so the two callers no longer re-split a string. Applied to agent-audit's copy as well. No agent ships a sources.md today, so it is latent there, but it is the same defect. This is a third gate blind spot alongside #117 and #118, and was unfiled. One real defect surfaced immediately and is fixed separately. Refs #99 --- .../scripts/validate-provenance.sh | 42 +++++++++++-- .../scripts/validate-provenance.sh | 62 ++++++++++++++++--- .../scripts/validate-provenance.sh | 42 +++++++++++-- .../scripts/validate-provenance.sh | 62 ++++++++++++++++--- 4 files changed, 182 insertions(+), 26 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh index 20052bd..82395c3 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh @@ -131,6 +131,15 @@ def parse_h2_slugs(content): return re.findall(r'^## (.+)$', content, re.MULTILINE) def parse_contributing_files(content, slug): + """Find the Contributing files for a given slug H2 in content. + + Accepts the inline form and the bullet form; recognising only the + inline one silently skips the contributing-file checks on every + sources.md written the other way. Returns a list of paths with any + trailing parenthetical note stripped; "(none)" returns an empty list + and a slug with no entry returns None. Kept behaviourally identical to + skill-audit's copy, which is where the bug was found. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -139,10 +148,36 @@ def parse_contributing_files(content, slug): if not m: return None block = m.group(1) + + def strip_note(entry): + return re.sub(r'\s*\(.*$', '', entry).strip() + 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] + + 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 def parse_research_doc(content, slug): pattern = re.compile( @@ -242,9 +277,8 @@ 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()] + cf_files = parse_contributing_files(sources_content, slug) + if cf_files: for cf_rel in cf_files: cf_abs = os.path.join(plugin_root, cf_rel) if not os.path.isfile(cf_abs): diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh index edfe211..619ac47 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh @@ -94,8 +94,24 @@ def parse_h2_slugs(content): return re.findall(r'^## (.+)$', content, re.MULTILINE) 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 checks 4 and 5 + on every skill using the second: + + - **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. + A "(none)" value returns an empty list; a slug with no Contributing + files entry at all returns None. Note the bullet form's notes may + themselves contain commas, so the list is built per bullet rather than + by splitting the joined value. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -104,10 +120,40 @@ 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] + + # 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() + rest = block[cf_m.end():] + files = [] + for line in rest.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 def parse_research_doc(content, slug): """Find the Research doc value for a given slug H2 in content.""" @@ -300,10 +346,8 @@ research_docs_seen = {} # abs_path → set of slugs in sources.md that referenc 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()] + cf_files = parse_contributing_files(sources_content, slug) + if cf_files: for cf_rel in cf_files: cf_abs = os.path.join(skill_dir, cf_rel) if not os.path.isfile(cf_abs): @@ -374,8 +418,8 @@ for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): # 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`": diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh index 20052bd..82395c3 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh @@ -131,6 +131,15 @@ def parse_h2_slugs(content): return re.findall(r'^## (.+)$', content, re.MULTILINE) def parse_contributing_files(content, slug): + """Find the Contributing files for a given slug H2 in content. + + Accepts the inline form and the bullet form; recognising only the + inline one silently skips the contributing-file checks on every + sources.md written the other way. Returns a list of paths with any + trailing parenthetical note stripped; "(none)" returns an empty list + and a slug with no entry returns None. Kept behaviourally identical to + skill-audit's copy, which is where the bug was found. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -139,10 +148,36 @@ def parse_contributing_files(content, slug): if not m: return None block = m.group(1) + + def strip_note(entry): + return re.sub(r'\s*\(.*$', '', entry).strip() + 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] + + 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 def parse_research_doc(content, slug): pattern = re.compile( @@ -242,9 +277,8 @@ 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()] + cf_files = parse_contributing_files(sources_content, slug) + if cf_files: for cf_rel in cf_files: cf_abs = os.path.join(plugin_root, cf_rel) if not os.path.isfile(cf_abs): diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh index edfe211..619ac47 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh @@ -94,8 +94,24 @@ def parse_h2_slugs(content): return re.findall(r'^## (.+)$', content, re.MULTILINE) 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 checks 4 and 5 + on every skill using the second: + + - **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. + A "(none)" value returns an empty list; a slug with no Contributing + files entry at all returns None. Note the bullet form's notes may + themselves contain commas, so the list is built per bullet rather than + by splitting the joined value. + """ pattern = re.compile( r'^## ' + re.escape(slug) + r'\s*\n(.*?)(?=^## |\Z)', re.MULTILINE | re.DOTALL @@ -104,10 +120,40 @@ 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] + + # 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() + rest = block[cf_m.end():] + files = [] + for line in rest.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 def parse_research_doc(content, slug): """Find the Research doc value for a given slug H2 in content.""" @@ -300,10 +346,8 @@ research_docs_seen = {} # abs_path → set of slugs in sources.md that referenc 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()] + cf_files = parse_contributing_files(sources_content, slug) + if cf_files: for cf_rel in cf_files: cf_abs = os.path.join(skill_dir, cf_rel) if not os.path.isfile(cf_abs): @@ -374,8 +418,8 @@ for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): # 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`": -- 2.43.0 From c7c9311d80e90b088d1eb21ecd9f1faa3d7438b7 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:52:06 +0000 Subject: [PATCH 57/89] docs(gates): refresh the retrofit status to measured state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/spec/gates.md still described both ADR-0020 gates as "currently red" and tabled the pre-retrofit figures: 26 of 39 descriptions and 9 of 39 bodies over their FAIL tier, 2 dangling targets, 58 SUGGESTIONs, and 10 Kyberforge.CompositionNote errors across four gitea-* skills. Measured now: 0, 0, 0, 33 and 0. The branch correctly left ADR-0020 itself untouched, since it self-pins every citation to base commit f9b919d. gates.md carries no such pin, and AGENTS.md names it three times as the authoritative reasoning layer — so the shallow doc and the deep doc it defers to asserted opposite facts about the same two gates, with the stale one telling a reader that an unrelated one-line fix to a skill is blocked pending a retrofit that is already done. Also corrects the apm-orchestrate agent body figure, which drifted from 1,080 to 1,113 across this branch. Its point is that the numbers are live evidence for leaving that hook's files: pattern alone, so a reader who re-measures and gets a third value loses the argument. AGENTS.md gains the second cause of the references/ blind spot: besides the Kyberforge style being scoped [**/SKILL.md], the vale-audit-prefilter-skill hook filters on a SKILL.md-only files: pattern, so widening .vale.ini alone would change nothing. It also no longer implies the kyberforge wave was the end of the work. Refs #99 #117 --- AGENTS.md | 2 +- docs/spec/gates.md | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 078ffd2..02ed5da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Issue #99 retrofitted them plugin by plugin and `kyberforge` was the last wave. Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and the `Kyberforge` style is scoped `[**/SKILL.md]`, so every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. Check both: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Issue #99 retrofitted them plugin by plugin — `kyberforge` was the last plugin wave, followed by corpus-wide passes and two rounds of independent audit fixes. Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. That blind spot has **two** independent causes and closing either alone changes nothing: the `Kyberforge` style is scoped `[**/SKILL.md]`, *and* the `vale-audit-prefilter-skill` hook filters on `files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'`, so a reference file is never handed to Vale whatever the style says (#117). Check both gates: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 8fa6a99..7fb8ea9 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -282,7 +282,7 @@ bash scripts/skill-size-check.sh plugins/*/.apm/agents/*.agent.md ``` exits 1 today with 900-word body FAILs on `git-orchestrate` (933), `gitea-orchestrate` (1,199) and -`apm-orchestrate` (1,080). Agent files escape only because the hook definitions filter on `SKILL.md` +`apm-orchestrate` (1,113). Agent files escape only because the hook definitions filter on `SKILL.md` — a file-pattern accident that happens to implement the design, not the design itself. **Do not "extend" that hook's `files:` pattern to cover agents** on the assumption that the script already knows the difference; doing so silently enforces a gate ADR-0020 declines to set. @@ -292,20 +292,22 @@ knows the difference; doing so silently enforces a gate ADR-0020 declines to set **The ADR-0020 gates ship hot, with no baseline file.** A shrinking baseline recording each non-compliant skill's current numbers was considered and rejected in favour of hot gates. -Two independent hot gates are currently red, and the first will not warn you about the second. +**The corpus is now clean on both gates.** Issue **#99** retrofitted all 39 skills plugin by plugin; +`kyberforge` was the last wave, followed by two corpus-wide passes. | Gate | Current findings | |---|---| -| `skill-size-check` | **26 of 39** descriptions and **9 of 39** bodies exceed their FAIL tier; 2 dangling targets; 58 SUGGESTIONs | -| `Kyberforge.CompositionNote` (Vale) | **10 errors across four skills**: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-workflow` | +| `skill-size-check` | **0 of 39** descriptions and **0 of 39** bodies exceed their FAIL tier; 0 dangling targets; 31 SUGGESTIONs | +| `Kyberforge.CompositionNote` (Vale) | **0 errors** — the four `gitea-*` carriers were all retrofitted | `Kyberforge.CompositionNote` is the ADR-0020 Vale rule banning composition and architecture prose -from a description. Every Vale rule here is `level: error` with no ignorable tier, so touching any of -those four skills means fixing its prose findings as well as its size findings. +from a description. Every Vale rule here is `level: error` with no ignorable tier, so a description +that reintroduces one blocks the commit even though no skill carries one today. -Consequence: editing a non-compliant skill *for any reason* means retrofitting it to the contract -first — a one-line fix to `gitea-prs` cannot be committed until that skill complies. This is -deliberate; it guarantees convergence and avoids a half-state. Tracked as Gitea issue **#99**. +Because nothing is grandfathered, the gates now bite on **first commit**: a new skill, or an edit +that pushes a description past 400 characters or a body past 900 words, is blocked until it +complies. That is the steady state the retrofit was for — it is no longer true that an unrelated +one-line fix to a skill requires retrofitting that skill first. Check where a skill stands before starting, and check **both** gates: -- 2.43.0 From 095929142f5170d0e45612b673083f6f0e67233c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Sun, 30 Aug 2026 20:52:16 +0000 Subject: [PATCH 58/89] chore(release): bump all six plugin versions for the ADR-0020 retrofit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No wave on this branch bumped a version across 56 commits, though apm-workflow's own configure.md states the policy: bump a package's apm.yml version: whenever anything reaching its compiled output changes. All six local packages have substantive .apm/ edits here. Minor rather than patch. The retrofit rewrote every skill description, which is the routing surface a caller matches against, and redistributed bodies into references/. Behaviour is preserved but discovery changes, so this is more than a fix. The same file is explicit that versions are per package — editing plugins/foo/.apm/ never bumps plugins/bar — so this is six independent bumps that happen to land together, not one release number. Under the per_package strategy the catalog carries a second copy in marketplace.packages[], and apm-marketplace-check fails the push when the two disagree; it cannot see a bump skipped in both, which is the state the branch was in. executables.allow is version-pinned by apm's design and moves with kyberforge, or the SessionStart hook silently stops deploying (ADR-0019). The comment above that block predicted exactly this; the pre-push suite caught it when the first bump orphaned the key. Refs #99 --- .claude-plugin/marketplace.json | 12 ++++++------ .github/plugin/marketplace.json | 12 ++++++------ apm.yml | 14 +++++++------- plugins/bin/.claude-plugin/plugin.json | 2 +- plugins/bin/.github/plugin/plugin.json | 2 +- plugins/bin/apm.yml | 2 +- plugins/core/.claude-plugin/plugin.json | 2 +- plugins/core/.github/plugin/plugin.json | 2 +- plugins/core/apm.yml | 2 +- plugins/git/.claude-plugin/plugin.json | 2 +- plugins/git/.github/plugin/plugin.json | 2 +- plugins/git/apm.yml | 2 +- plugins/gitea/.claude-plugin/plugin.json | 2 +- plugins/gitea/.github/plugin/plugin.json | 2 +- plugins/gitea/apm.yml | 2 +- plugins/kyberforge/.claude-plugin/plugin.json | 2 +- plugins/kyberforge/.github/plugin/plugin.json | 2 +- plugins/kyberforge/apm.yml | 2 +- plugins/lint/.claude-plugin/plugin.json | 2 +- plugins/lint/.github/plugin/plugin.json | 2 +- plugins/lint/apm.yml | 2 +- 21 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5a19094..db75088 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.6.0", + "version": "1.7.0", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.1.5", + "version": "1.2.0", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.3.5", + "version": "1.4.0", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.3.6", + "version": "1.4.0", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.1.1", + "version": "1.2.0", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.1.6", + "version": "1.2.0", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 5a19094..db75088 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.6.0", + "version": "1.7.0", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.1.5", + "version": "1.2.0", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.3.5", + "version": "1.4.0", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.3.6", + "version": "1.4.0", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.1.1", + "version": "1.2.0", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.1.6", + "version": "1.2.0", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/apm.yml b/apm.yml index 7ae2ec4..bd69ff0 100644 --- a/apm.yml +++ b/apm.yml @@ -42,7 +42,7 @@ dependencies: # after a kyberforge release, check this first. executables: allow: - kyberforge#1.6.0: + kyberforge#1.7.0: hooks: true bin: true @@ -79,31 +79,31 @@ marketplace: - name: kyberforge description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. source: ./plugins/kyberforge - version: 1.6.0 + version: 1.7.0 category: Developer Tools - name: bin description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. source: ./plugins/bin - version: 1.1.5 + version: 1.2.0 category: Utilities - name: git description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it. source: ./plugins/git - version: 1.3.5 + version: 1.4.0 category: Version Control - name: gitea description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone. source: ./plugins/gitea - version: 1.3.6 + version: 1.4.0 category: Version Control - name: core description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. source: ./plugins/core - version: 1.1.1 + version: 1.2.0 category: Productivity - name: mattpocock-skills @@ -115,5 +115,5 @@ marketplace: - name: lint description: Skills and agents for configuring and running linters. source: ./plugins/lint - version: 1.1.6 + version: 1.2.0 category: Developer Tools diff --git a/plugins/bin/.claude-plugin/plugin.json b/plugins/bin/.claude-plugin/plugin.json index d3644c8..8a181b6 100644 --- a/plugins/bin/.claude-plugin/plugin.json +++ b/plugins/bin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.1.5", + "version": "1.2.0", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/.github/plugin/plugin.json b/plugins/bin/.github/plugin/plugin.json index 18814d8..a0c4e43 100644 --- a/plugins/bin/.github/plugin/plugin.json +++ b/plugins/bin/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.1.5", + "version": "1.2.0", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/apm.yml b/plugins/bin/apm.yml index fd3c6d0..69d4adf 100644 --- a/plugins/bin/apm.yml +++ b/plugins/bin/apm.yml @@ -1,5 +1,5 @@ name: bin -version: 1.1.5 +version: 1.2.0 description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. author: name: Defame1297 diff --git a/plugins/core/.claude-plugin/plugin.json b/plugins/core/.claude-plugin/plugin.json index 167b764..607b382 100644 --- a/plugins/core/.claude-plugin/plugin.json +++ b/plugins/core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.1.1", + "version": "1.2.0", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/.github/plugin/plugin.json b/plugins/core/.github/plugin/plugin.json index 167b764..607b382 100644 --- a/plugins/core/.github/plugin/plugin.json +++ b/plugins/core/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.1.1", + "version": "1.2.0", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/apm.yml b/plugins/core/apm.yml index 140958f..191931c 100644 --- a/plugins/core/apm.yml +++ b/plugins/core/apm.yml @@ -1,5 +1,5 @@ name: core -version: 1.1.1 +version: 1.2.0 description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. author: name: Defame1297 diff --git a/plugins/git/.claude-plugin/plugin.json b/plugins/git/.claude-plugin/plugin.json index bf02e91..b3643a4 100644 --- a/plugins/git/.claude-plugin/plugin.json +++ b/plugins/git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git", - "version": "1.3.5", + "version": "1.4.0", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", "author": { "name": "Defame1297", diff --git a/plugins/git/.github/plugin/plugin.json b/plugins/git/.github/plugin/plugin.json index bf02e91..b3643a4 100644 --- a/plugins/git/.github/plugin/plugin.json +++ b/plugins/git/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git", - "version": "1.3.5", + "version": "1.4.0", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", "author": { "name": "Defame1297", diff --git a/plugins/git/apm.yml b/plugins/git/apm.yml index 08c885c..2541baf 100644 --- a/plugins/git/apm.yml +++ b/plugins/git/apm.yml @@ -1,5 +1,5 @@ name: git -version: 1.3.5 +version: 1.4.0 description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it. author: name: Defame1297 diff --git a/plugins/gitea/.claude-plugin/plugin.json b/plugins/gitea/.claude-plugin/plugin.json index 983109a..3b97d12 100644 --- a/plugins/gitea/.claude-plugin/plugin.json +++ b/plugins/gitea/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gitea", - "version": "1.3.6", + "version": "1.4.0", "description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.", "author": { "name": "Defame1297", diff --git a/plugins/gitea/.github/plugin/plugin.json b/plugins/gitea/.github/plugin/plugin.json index 983109a..3b97d12 100644 --- a/plugins/gitea/.github/plugin/plugin.json +++ b/plugins/gitea/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gitea", - "version": "1.3.6", + "version": "1.4.0", "description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.", "author": { "name": "Defame1297", diff --git a/plugins/gitea/apm.yml b/plugins/gitea/apm.yml index d08b3ea..907f6e5 100644 --- a/plugins/gitea/apm.yml +++ b/plugins/gitea/apm.yml @@ -1,5 +1,5 @@ name: gitea -version: 1.3.6 +version: 1.4.0 description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone. author: name: Defame1297 diff --git a/plugins/kyberforge/.claude-plugin/plugin.json b/plugins/kyberforge/.claude-plugin/plugin.json index 071ecc2..85eef5e 100644 --- a/plugins/kyberforge/.claude-plugin/plugin.json +++ b/plugins/kyberforge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.6.0", + "version": "1.7.0", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/.github/plugin/plugin.json b/plugins/kyberforge/.github/plugin/plugin.json index 071ecc2..85eef5e 100644 --- a/plugins/kyberforge/.github/plugin/plugin.json +++ b/plugins/kyberforge/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.6.0", + "version": "1.7.0", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/apm.yml b/plugins/kyberforge/apm.yml index b59a56e..9effd10 100644 --- a/plugins/kyberforge/apm.yml +++ b/plugins/kyberforge/apm.yml @@ -1,5 +1,5 @@ name: kyberforge -version: 1.6.0 +version: 1.7.0 description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. author: name: Defame1297 diff --git a/plugins/lint/.claude-plugin/plugin.json b/plugins/lint/.claude-plugin/plugin.json index 925ec0a..926c0c8 100644 --- a/plugins/lint/.claude-plugin/plugin.json +++ b/plugins/lint/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lint", - "version": "1.1.6", + "version": "1.2.0", "description": "Skills and agents for configuring and running linters.", "author": { "name": "Defame1297", diff --git a/plugins/lint/.github/plugin/plugin.json b/plugins/lint/.github/plugin/plugin.json index 925ec0a..926c0c8 100644 --- a/plugins/lint/.github/plugin/plugin.json +++ b/plugins/lint/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lint", - "version": "1.1.6", + "version": "1.2.0", "description": "Skills and agents for configuring and running linters.", "author": { "name": "Defame1297", diff --git a/plugins/lint/apm.yml b/plugins/lint/apm.yml index 6b66297..452ca65 100644 --- a/plugins/lint/apm.yml +++ b/plugins/lint/apm.yml @@ -1,5 +1,5 @@ name: lint -version: 1.1.6 +version: 1.2.0 description: Skills and agents for configuring and running linters. author: name: Defame1297 -- 2.43.0 From db5a42641690ef429e767ed0341904fe3d18b491 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:07 +0000 Subject: [PATCH 59/89] fix(gates): make the ADR-0020 boundary check parse what skills actually write The routing-target check understood only a single-arrow clause naming a bare skill, so most real boundary prose was silently skipped rather than verified. Two of those silences were fail-open: an unrecognised token following a target dropped that target from the check entirely, and a skill directory with no SKILL.md still resolved as a valid routing target, so a broken route passed. Multi-target arrow clauses now draw a SUGGESTION instead of being ignored, hand-invocation phrasing is carved out so it is not read as a route, and a dotted filename parses into a new `unparsed` status rather than disappearing. Three test fixtures had been relying on the SKILL.md-less directory resolving as a target; they are corrected alongside the check. Addresses #107, #108, #110. --- .../skills/agent-audit/scripts/validate.sh | 245 ++++++++++++++- .../skills/skill-audit/scripts/validate.sh | 246 ++++++++++++++- .../skills/skill-audit/tests/validate.bats | 177 ++++++++++- .../skills/agent-audit/scripts/validate.sh | 245 ++++++++++++++- .../skills/skill-audit/scripts/validate.sh | 246 ++++++++++++++- scripts/skill-size-check.sh | 291 ++++++++++++++++-- tests/test-adr0020-targets.sh | 199 +++++++++++- tests/test-skill-size-check.sh | 129 +++++++- 8 files changed, 1688 insertions(+), 90 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index b13a210..c24f739 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -253,7 +253,15 @@ 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)): base = os.path.basename(path) @@ -557,12 +565,33 @@ 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) +# 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" @@ -582,9 +611,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 @@ -643,11 +680,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): @@ -706,6 +758,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). @@ -722,6 +853,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): @@ -730,7 +872,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) @@ -810,6 +955,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 @@ -977,8 +1163,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) @@ -988,13 +1180,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 @@ -1011,10 +1203,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 @@ -1249,8 +1462,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. @@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider): else: if PLACEHOLDER_RE.search(folded): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") - check_description_budget(folded, local_fname) - check_boundary(folded, fpath, local_fname) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body if not body.strip(): diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index e169390..a871fe2 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -179,7 +179,15 @@ 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)): base = os.path.basename(path) @@ -483,12 +491,33 @@ 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) +# 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" @@ -508,9 +537,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 @@ -569,11 +606,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): @@ -632,6 +684,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). @@ -648,6 +779,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): @@ -656,7 +798,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) @@ -736,6 +881,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 @@ -909,6 +1095,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 @@ -1023,10 +1218,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})") @@ -1080,13 +1278,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 diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats index f63d799..d41c364 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats @@ -70,13 +70,23 @@ PY # this repo's live skills. Echoes the subject skill's directory. # # <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,168 @@ 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" +} diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh index b13a210..c24f739 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh @@ -253,7 +253,15 @@ 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)): base = os.path.basename(path) @@ -557,12 +565,33 @@ 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) +# 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" @@ -582,9 +611,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 @@ -643,11 +680,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): @@ -706,6 +758,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). @@ -722,6 +853,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): @@ -730,7 +872,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) @@ -810,6 +955,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 @@ -977,8 +1163,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) @@ -988,13 +1180,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 @@ -1011,10 +1203,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 @@ -1249,8 +1462,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. @@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider): else: if PLACEHOLDER_RE.search(folded): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") - check_description_budget(folded, local_fname) - check_boundary(folded, fpath, local_fname) + by_hand = hand_invoked(fm) + check_description_budget(folded, local_fname, by_hand) + check_boundary(folded, fpath, local_fname, by_hand) # body if not body.strip(): diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh index e169390..a871fe2 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh @@ -179,7 +179,15 @@ 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)): base = os.path.basename(path) @@ -483,12 +491,33 @@ 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) +# 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" @@ -508,9 +537,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 @@ -569,11 +606,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): @@ -632,6 +684,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). @@ -648,6 +779,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): @@ -656,7 +798,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) @@ -736,6 +881,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 @@ -909,6 +1095,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 @@ -1023,10 +1218,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})") @@ -1080,13 +1278,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 diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index 8ce8c7c..cf15c71 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -34,26 +34,44 @@ set -euo pipefail # as a proxy (Python's str.split(), the same primitive # skill-audit/scripts/validate.sh applies to these two constants; `wc -w` # disagrees with it on Unicode separators, which is why the awk pass that used -# to live in the loop below is gone). Measured over this repo's 39 in-scope SKILL.md files, characters per -# word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard -# ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 / -# 1.81 tokens per word. +# to live in the loop below is gone). +# +# THE MEASUREMENT BASIS, stated because the previous re-measure drifted onto a +# different one and the numbers moved without the prose noticing: characters +# per word is len(text) / len(text.split()) over the WHOLE FILE, whitespace +# included, on plugins/*/.apm/skills/*/SKILL.md. Counting only non-whitespace +# characters gives a materially lower figure (4.90 / 5.52 / 5.54 / 6.19 today) +# and is not the basis MAX_WORDS is calibrated against. +# +# Measured over this repo's 39 in-scope SKILL.md files (2026-08-31, after the +# ADR-0020 retrofit), characters per word runs min 5.93 / median 6.67 / mean +# 6.63 / max 7.34. At the standard ~4-characters-per-token English +# approximation that is 1.48 / 1.67 / 1.66 / 1.84 tokens per word. # # MAX_WORDS=2770 is therefore calibrated to the corpus WORST case rather than -# its median: 2770 words at the densest observed 7.22 chars/word is ~20,000 -# characters, or ~5,000 tokens at the 4-characters-per-token approximation. So -# what this gate guarantees is "under 5,000 tokens even for the densest prose +# its median: 2770 words at the densest observed 7.34 chars/word is ~20,300 +# characters, or ~5,090 tokens at the 4-characters-per-token approximation. So +# what this gate guarantees is "about 5,000 tokens even for the densest prose # the corpus has produced" — the earlier median-calibrated MAX_WORDS=2900 let -# such a file sit at exactly the ceiling and still spend ~5,240 tokens. A -# median-density file at 2770 words spends ~4,700 tokens, so typical prose -# gives up ~130 words of headroom to close that gap. The largest SKILL.md in -# the repo is 2,760 words whole-file (skill-author), twelve words under the -# ceiling — this is a gate two files have already grown into, not headroom. +# such a file sit at exactly the ceiling and spend ~5,320 tokens. A +# median-density file at 2770 words spends ~4,620 tokens, so typical prose +# gives up ~140 words of headroom to close that gap. Densest file today: +# git-commits at 7.34 chars/word. +# +# THE CORPUS IS NOWHERE NEAR THIS CEILING ANY MORE, and the note that used to +# stand here — "a gate two files have already grown into" — described the +# pre-retrofit corpus and is now wrong by a factor of three. The largest +# SKILL.md is write-docs at 914 whole-file words, then vale-run at 874; +# skill-author, the old high-water mark at 2,760, is down to 661. MAX_WORDS is +# a spec-conformance backstop with roughly 1,850 words of slack, and the gate +# that actually bites is ADR-0020's 900-word body budget below it. Do not read +# the two as redundant: they measure different spans, and a file can sit well +# inside one while failing the other. # # It is a one-sided proxy in the useful direction — nothing under the word # ceiling is wildly over the token ceiling — but it is not exact BPE -# tokenization and does not replace one. Re-measure the corpus before treating -# any of these numbers as still current. +# tokenization and does not replace one. Re-measure the corpus, on the basis +# stated above, before treating any of these numbers as still current. # # python3 AND PyYAML are required for the ADR-0020 half, and both are hard # dependencies rather than best-effort: python3 because pre-commit (which is how @@ -263,7 +281,15 @@ 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)): base = os.path.basename(path) @@ -567,12 +593,33 @@ 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) +# 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" @@ -592,9 +639,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 @@ -653,11 +708,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): @@ -716,6 +786,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). @@ -732,6 +881,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): @@ -740,7 +900,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) @@ -820,6 +983,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 @@ -1009,6 +1213,9 @@ for path in files: body = content[fm_match.end():] skill_dir = os.path.dirname(os.path.abspath(path)) + # ADR-0020's hand-invocation carve-out. See hand_invoked() for what it lifts + # and, more importantly, what it does not (issue #108). + by_hand = hand_invoked(fm_match.group(1)) # An absent or empty description is an ERROR here too, not a silent skip. # All three ADR-0020 scripts have to agree on this input: the description is @@ -1030,7 +1237,12 @@ for path in files: "enumeration, output-format detail, composition notes and implementation " "detail to the body or README.md." % (path, dlen, DESC_MAX_CHARS)) - elif dlen > DESC_SUGGEST_CHARS: + elif dlen > DESC_SUGGEST_CHARS and not by_hand: + # The 250-character TARGET is a routing-quality budget: it exists to + # keep the preloaded listing small and the trigger clause sharp. A + # hand-invoked description is in no listing, so there is no budget to + # spend and no shape to enforce. The 400-character FAIL above still + # applies — see hand_invoked(). suggest("%s: description is %d characters, over the %d-character target " "(ADR-0020, hard fail at %d)." % (path, dlen, DESC_SUGGEST_CHARS, DESC_MAX_CHARS)) @@ -1073,15 +1285,40 @@ for path in files: round(100.0 * section_words / body_words), round(100.0 * GOTCHA_MAX_BODY_FRACTION))) - # Missing boundary clause. SUGGESTION, not ERROR: detecting the absence is + # Boundary clause. SUGGESTION, not ERROR: detecting the absence is # deterministic, but whether this particular skill warrants one is the # auditor's call. Both accepted shapes count — the prose markers and # ADR-0020's compressed `Not <thing> -> <name>` arrow. - if desc and not has_boundary_clause(desc): - suggest("%s: description has no boundary clause (ADR-0020). Add the prose form " - "(\"Do not use for X — use `y` instead\") or the compressed form " - "(\"Not X -> y\") so the router knows where NOT to send this skill." - % path) + # + # THREE outcomes, not two. Reporting "no boundary clause" for a clause that + # is present and merely unparsed is a wrong finding, not a strict one, and + # it cost three authors a reworded clause before it was diagnosed (#110). + # + # Skipped entirely for a hand-invoked skill: the contract gives it one plain + # sentence with no boundary clause, so the finding is wrong and its remedy + # names a router that cannot see the skill (#108). + if desc and not by_hand: + status = boundary_clause_status(desc) + if status == 'absent': + suggest("%s: description has no boundary clause (ADR-0020). Add the prose form " + "(\"Do not use for X — use `y` instead\") or the compressed form " + "(\"Not X -> y\") so the router knows where NOT to send this skill." + % path) + elif status == 'unparsed': + suggest("%s: 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 " + "(ADR-0020). The clause is present — this is a PARSE failure, not a " + "missing clause. 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." % path) + # One arrow, one target. A second name after the arrow is resolved by + # nothing and reported by nothing, so the clause claims coverage it does + # not have (#107). + for first, second in multi_target_arrow_clauses(desc): + suggest("%s: an arrow boundary clause names more than one target ('%s', then " + "'%s'), and only the first is resolved — the second is checked by " + "nothing (ADR-0020). Split it into one arrow per target: " + "\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second)) targets = boundary_targets(desc) if targets: diff --git a/tests/test-adr0020-targets.sh b/tests/test-adr0020-targets.sh index 8fd7d69..3247ee5 100755 --- a/tests/test-adr0020-targets.sh +++ b/tests/test-adr0020-targets.sh @@ -92,8 +92,14 @@ build_tree "$TMPDIR_T/no-claude" build_tree "$TMPDIR_T/with-claude" # The deployed tree, present only in the second root. Both a skill and an agent, # because both are valid routing targets and both would leak. -mkdir -p "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" \ - "$TMPDIR_T/with-claude/.claude/agents" +# +# The skill gets a real SKILL.md. That is not decoration: a directory under +# skills/ is a resolvable name only when it HOLDS one, so an empty directory +# would dangle for the wrong reason and the assertion below would pass without +# testing the deployed-tree rule at all. +mkdir -p "$TMPDIR_T/with-claude/.claude/agents" +write_skill "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" deployed-only-skill \ + "Use when doing the deployed thing. Do not use for anything else." : > "$TMPDIR_T/with-claude/.claude/agents/deployed-only-agent.md" run_subject() { @@ -133,7 +139,8 @@ fi echo "" echo "--- with no authoring root, a deployed .claude/ tree IS the universe ---" CONSUMER="$TMPDIR_T/consumer" -mkdir -p "$CONSUMER/.claude/skills/deployed-only-skill" +write_skill "$CONSUMER/.claude/skills/deployed-only-skill" deployed-only-skill \ + "Use when doing the deployed thing. Do not use for anything else." write_skill "$CONSUMER/.claude/skills/my-skill" my-skill \ "Use when doing the thing. Do not use for the other thing — use deployed-only-skill instead." set +e @@ -384,8 +391,12 @@ for bait_root in "$BAIT_FRESH" "$BAIT_DEPLOYED"; do write_skill "$bait_root/plugins/bin/.apm/skills/deployed-tree-probe" deployed-tree-probe \ "Use when doing the probe thing. Do not use for the other thing — use $BAIT_NAME instead." done -# Only the deployed copy gets the name planted where `apm install` would put it. -mkdir -p "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_DEPLOYED/.claude/agents" +# Only the deployed copy gets the name planted where `apm install` would put it, +# as a REAL skill directory holding a SKILL.md — an empty directory is not a +# resolvable name, so baiting with one would make the A/B pass vacuously. +mkdir -p "$BAIT_DEPLOYED/.claude/agents" +write_skill "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_NAME" \ + "Use when doing the bait thing. Do not use for anything else." BAIT_FRESH_DANGLING="$(dangling_set "$BAIT_FRESH/plugins")" BAIT_DEPLOYED_DANGLING="$(dangling_set "$BAIT_DEPLOYED/plugins")" @@ -511,13 +522,24 @@ grammar_case() { # The four phrasings that were hard dangling FAILs with no suppression. All four # are lifted from real descriptions in this corpus. -grammar_case fp-precommit-hooks silent "" \ +# +# THEY ARE `suggests`, NOT `silent`, AND THE DIFFERENCE IS THE POINT. The +# follower rule takes away the power to BLOCK a commit on a compound modifier; +# it does not take away visibility, and it used to. FOLLOWER_OK is a closed +# whitelist of about eighty words, so a non-terminal verdict means "the next +# token is outside a list someone maintains by hand", not "this is prose" — and +# `continue`ing on it made the gate fail OPEN on its own unfamiliarity: any +# target followed by an unlisted word was neither blocked nor mentioned at any +# tier. Asserting silence here pinned that hole in place. The assertion that +# still matters is `!= ERROR`, which `suggests` checks, and which is what keeps +# a false positive from stopping a commit. +grammar_case fp-precommit-hooks suggests "routes to 'pre-commit'" \ "Use when running the linter. Use pre-commit hooks instead of ad-hoc scripts." -grammar_case fp-pull-request silent "" \ +grammar_case fp-pull-request suggests "routes to 'pull-request'" \ "Use when opening changes. Invoke the pull-request template instead of writing one by hand." -grammar_case fp-conventional silent "" \ +grammar_case fp-conventional suggests "routes to 'conventional-commits'" \ "Use when writing history. Use conventional-commits formatting rather than free-form messages." -grammar_case fp-prepush-backticked silent "" \ +grammar_case fp-prepush-backticked suggests "routes to 'pre-push'" \ "Use when checking a branch. Do not use for local edits — run the \`pre-push\` hooks instead." echo "" @@ -599,6 +621,165 @@ grammar_case lowercase-start suggests "routes to 'no-such-lower-skill'" \ grammar_case backtick-start suggests "routes to 'no-such-tick-skill'" \ "Use when doing the thing. Use sibling-skill for the main case. \`no-such-tick-skill\` is not for this — do not use it instead." +# --------------------------------------------------------------------------- +# 2a. Route NOTATION always blocks, whatever token follows it +# --------------------------------------------------------------------------- +# FOLLOWER_OK is a closed whitelist of about eighty words. A target followed by +# anything outside it was non-terminal, and `/name` reached _add() with +# strict=None, so it fell to the follower test and lost the power to block — +# contradicting the header's own promise that route notation "always blocks", +# for the one form Claude Code actually uses. Combined with the old `continue` +# in unresolved_targets(), `use /no-such-skill afterwards.` exited 0 with no +# output at all: the gate failed OPEN on a word nobody had thought to enumerate. +# +# "afterwards" is the probe in every case below. It is ordinary English, it is +# not in FOLLOWER_OK, and it is not going to be added to it. +echo "" +echo "--- route notation blocks even when the following token is outside FOLLOWER_OK ---" +grammar_case notation-slash-unlisted errors "routes to 'no-such-slash-skill'" \ + "Use when doing the thing. Do not use for improvements — use /no-such-slash-skill afterwards." +grammar_case notation-arrow-unlisted errors "routes to 'no-such-arrow-skill'" \ + "Use when doing the thing. Not the other thing -> no-such-arrow-skill afterwards." + +echo "" +echo "--- a target the follower rule cannot vouch for is REPORTED, never invisible ---" +# The other half of the same defect, and the one that cost visibility rather +# than enforcement: a PROSE-form target with an unlisted follower may not block +# (that is what the follower rule is for) but it must still be named. Silence +# here is the vacuous-green shape the whole script forbids itself. +grammar_case follower-unlisted-bare suggests "routes to 'no-such-modifier-skill'" \ + "Use when doing the thing. Do not use for improvements — use no-such-modifier-skill afterwards." +grammar_case follower-unlisted-backticked suggests "routes to 'no-such-ticked-skill'" \ + "Use when doing the thing. Do not use for improvements — use \`no-such-ticked-skill\` afterwards." + +# --------------------------------------------------------------------------- +# 2b. Capitalised abbreviations do not over-split a sentence +# --------------------------------------------------------------------------- +# SENTENCE_SPLIT was the one pattern in the resolver built without re.I, so its +# five abbreviation lookbehinds only covered the lowercase spelling. `E.g.` and +# `I.e.` — the SENTENCE-INITIAL spellings, which is exactly where an +# abbreviation lands — matched none of them. 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. The lowercase twin of each case below is `abbrev-split` above and +# already passed, which is precisely why the gap survived. +echo "" +echo "--- a CAPITALISED abbreviation does not strand the corroborator ---" +grammar_case abbrev-split-caps-eg errors "routes to 'no-such-caps-eg-skill'" \ + "Use when doing the thing. Do not use for improvements — use sibling-skill first, E.g. \"run the audit\", then use no-such-caps-eg-skill instead." +grammar_case abbrev-split-caps-ie errors "routes to 'no-such-caps-ie-skill'" \ + "Use when doing the thing. Do not use for improvements — use sibling-skill first, I.e. \"run the audit\", then use no-such-caps-ie-skill instead." + +# --------------------------------------------------------------------------- +# 2c. A boundary clause naming a dotted filename (issue #110) +# --------------------------------------------------------------------------- +# `[^.;]` cannot cross the `.` in `AGENTS.md` or `.pre-commit-config.yaml`, so a +# clause naming a dotted file between "Not" and the arrow was invisible to both +# BOUNDARY_ARROW and ARROW_BOUNDARY. Two different failures came out of that: +# with a backticked target the clause was merely MISDIAGNOSED as missing, and +# with a BARE target it was never extracted at all, so the dangling check +# silently did not run on it. Both directions are pinned. +echo "" +echo "--- a boundary clause naming a dotted filename is seen, and its target is checked ---" +grammar_case dotted-bare-target errors "routes to 'no-such-dotted-skill'" \ + "Use when doing the thing. Not AGENTS.md -> no-such-dotted-skill." +grammar_case dotted-clause-seen silent "" \ + "Use when doing the thing. Not .pre-commit-config.yaml -> sibling-skill." +# The guard that makes the fix a fix and not a hole: a REAL sentence end still +# ends the clause. A `.` followed by whitespace terminates it exactly as before, +# so "Not applicable here." plus an arrow two sentences later is not a boundary +# clause and is still reported as one missing. +grammar_case dotted-sentence-end-guard suggests "has no boundary clause" \ + "Use when doing the thing. Not applicable here. Reproduce -> minimise." + +# --------------------------------------------------------------------------- +# 2d. "Present but unparsed" is a different finding from "missing" +# --------------------------------------------------------------------------- +# Issue #110's standing request. An arrow clause ALWAYS names a target, so one +# that yields none is a parse failure and must say so — telling the author the +# clause is missing sends them to add a second copy of a clause that is already +# there. The live shape is a single-word target, which is deliberately not +# matchable bare because `research`, `triage` and `forge` are all skill names +# AND ordinary English. +echo "" +echo "--- an arrow clause that yields no target is reported as unparsed, not as missing ---" +grammar_case arrow-single-word-target suggests "no target could be read" \ + "Use when doing the thing. Not the other thing -> forge." +# Control, so the case above is not satisfied by a check that fires on every +# arrow clause: the same clause with the target written in a shape the extractor +# can see produces nothing at all. +grammar_case arrow-single-word-marked silent "" \ + "Use when doing the thing. Not the other thing -> \`sibling-skill\`." + +# --------------------------------------------------------------------------- +# 2e. One arrow, one target (issue #107) +# --------------------------------------------------------------------------- +# Only the first target after an arrow is resolved: the conjunction continuation +# is wired to the prose route verbs and never to arrows. So the second name in +# `Not X -> a or b` was resolved by nothing and reported by nothing, and the +# audit then printed "1 of 1 boundary target(s) resolve" on a clause naming two. +# A typo in the second target shipped through a green gate. +# +# The fix rejects the shape rather than widening the extractor. The case below +# is the exact failure: a bare `Not ... ->` sentence carries no BOUNDARY_MARKER, +# so the backtick sweep does not run and the second target is genuinely +# invisible to every other rule in the resolver. +echo "" +echo "--- an arrow clause naming two targets is rejected, so the unchecked one is visible ---" +grammar_case multi-arrow-second-target suggests "names more than one target" \ + "Use when doing the thing. Not the other thing -> \`sibling-skill\` or \`no-such-second-target\`." +grammar_case multi-arrow-comma suggests "names more than one target" \ + "Use when doing the thing. Not the other thing -> \`sibling-skill\`, \`no-such-comma-target\`." +# Control: one arrow, one target — the convention the SUGGESTION is asking for — +# stays silent. Without this the case above is satisfied by a check that fires +# on every arrow clause in the corpus. +grammar_case multi-arrow-control silent "" \ + "Use when doing the thing. Not the other thing -> \`sibling-skill\`." + +# --------------------------------------------------------------------------- +# 2f. A skill directory with no SKILL.md is not a skill +# --------------------------------------------------------------------------- +# _collect_package() added a name for every directory matching skills/*/, with +# no check that anything was in it. A leftover empty directory — 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. +# The hook went green locally and red in a fresh clone: the same +# install-dependence the deployed-tree rule exists to remove, arriving through a +# different door. Both directions are asserted, because "never resolve" would +# also satisfy the first half. +echo "" +echo "--- an empty skills/<name>/ directory does not make a routing target resolve ---" +GHOST="$TMPDIR_T/ghost-dir" +write_skill "$GHOST/plugins/p/.apm/skills/my-skill" my-skill \ + "Use when doing the thing. Do not use for the other thing — use /ghost-skill instead." +mkdir -p "$GHOST/plugins/p/.apm/skills/ghost-skill" +# NOT wrapped in a helper function: command substitution runs the body in a +# subshell, so an exit status assigned inside one never reaches the caller — +# under `set -u` the second read of it aborts the suite. +set +e +GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)" +GHOST_RC=$? +set -e +if [[ $GHOST_RC -ne 0 && "$GHOST_OUT" == *"routes to 'ghost-skill'"* ]]; then + pass "a directory with no SKILL.md in it is not a resolvable name" +else + fail "an empty skills/ghost-skill/ directory resolved a routing target (exit $GHOST_RC): ${GHOST_OUT:-<empty>}" +fi +# The confirming half: drop a SKILL.md into the same directory and the identical +# description resolves. Without this the rule could be implemented as "skills/ +# never contributes anything" and still pass above. +write_skill "$GHOST/plugins/p/.apm/skills/ghost-skill" ghost-skill \ + "Use when doing the other thing. Do not use for anything else." +set +e +GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)" +GHOST_RC=$? +set -e +if [[ $GHOST_RC -eq 0 && -z "$GHOST_OUT" ]]; then + pass "the same directory WITH a SKILL.md resolves, so the rule is 'no SKILL.md' and not 'never'" +else + fail "a populated skills/ghost-skill/ directory still did not resolve (exit $GHOST_RC): ${GHOST_OUT:-<empty>}" +fi + # And the confirming half of the grammar rule: a compound-modifier target is # CONFIRM-ONLY, not ignored. When the name does exist it still counts as a route # — the rule suppresses the ERROR, it does not delete the target. diff --git a/tests/test-skill-size-check.sh b/tests/test-skill-size-check.sh index 3423850..2762e8b 100755 --- a/tests/test-skill-size-check.sh +++ b/tests/test-skill-size-check.sh @@ -331,13 +331,32 @@ PY # # The sibling plugin is what makes "every plugin in the monorepo contributes its # names" testable; without it a cross-plugin target and a typo are the same. +# +# Both sibling skill directories get a real SKILL.md, and that is load-bearing +# rather than tidiness: a skill directory is a resolvable name only if it HOLDS +# a SKILL.md. 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 +# — the same install-dependence the deployed-tree rule exists to remove. This +# fixture used to `mkdir` the two siblings and write nothing into them, so it +# was itself relying on the behaviour the resolver no longer has. make_tree_fixture() { - local label="$1" desc="$2" body_words="$3" root apm + local label="$1" desc="$2" body_words="$3" root apm sib root="$TMPDIR/tree-$label" apm="$root/plugins/subject-plugin/.apm" mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \ "$root/plugins/other-plugin/.apm/skills/cross-plugin-skill" : > "$apm/agents/sibling-agent.agent.md" + for sib in "$apm/skills/sibling-skill" \ + "$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"; do + { + echo "---" + echo "name: $(basename "$sib")" + echo "description: Use when doing the other thing. Do not use for anything else." + echo "---" + echo "" + echo "Do the thing." + } > "$sib/SKILL.md" + done { echo "---" echo "name: $label" @@ -364,8 +383,17 @@ expect_gate() { fail "$label (exit $status, output: ${out:-<empty>})" fi ;; + # The tier and the needle are matched ADJACENTLY — `*"SUGGESTION"*"$needle"*` + # — not as two independent substring tests. Independently, any output + # carrying a SUGGESTION anywhere and the needle anywhere satisfied the + # assertion, so a needle emitted at the WRONG TIER still passed: a finding + # that moved from SUGGESTION to a blocking ERROR line would be caught only + # by the exit-status test, and one that moved from SUGGESTION to INFO would + # not be caught at all. grammar_case's `suggests` branch in + # tests/test-adr0020-targets.sh has always matched them adjacently; this is + # the same rule. suggest) - if [[ $status -eq 0 && "$out" == *"SUGGESTION"* && "$out" == *"$needle"* ]]; then + if [[ $status -eq 0 && "$out" == *"SUGGESTION"*"$needle"* ]]; then pass "$label" else fail "$label (exit $status, output: ${out:-<empty>})" @@ -450,9 +478,14 @@ expect_gate "body at $((BODY_MAX_WORDS + 1)) words fails" \ echo "" echo "--- the body gate and the whole-file gate are independent measurements ---" BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")" +# The needle pins the COUNT, not the bare word "words". "words" appears in the +# whole-file ceiling message, in the body ceiling message and in the body target +# message alike, so it was satisfied by any of the three — including the one +# this case exists to prove does NOT fire. Naming the number is what makes the +# assertion about the body-only measurement. expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \ suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \ - "words" + "body is $((BODY_MAX_WORDS - 5)) words" BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")" BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")" if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then @@ -461,6 +494,96 @@ else fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate" fi +# --------------------------------------------------------------------------- +# ADR-0020's hand-invocation carve-out (issue #108) +# --------------------------------------------------------------------------- +# A skill carrying `disable-model-invocation: true` is removed 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, skill-author Step 2 and skill-audit's own Step 0 all give it ONE +# plain human-facing sentence: no trigger list, no boundary clause. No validator +# knew the field existed, so the boundary-clause SUGGESTION fired on exactly the +# shape the contract mandates, and its remedy — "so the router knows where NOT +# to send this skill" — named a router that cannot see the skill at all. +# +# The carve-out is NARROW and the half it does not cover is the half worth +# testing: the body is still loaded on invocation, so the body budget stands, +# and the 400-character ceiling stands because it is an outlier stop rather than +# a routing-quality target. Every case below asserts one of those two halves. +make_hand_invoked_fixture() { + local name="$1" desc="$2" body_words="$3" file + file="$TMPDIR/$name.md" + { + echo "---" + echo "name: $name" + echo "description: $desc" + echo "disable-model-invocation: true" + echo "---" + echo "" + python3 -c "print(' '.join(['word'] * $body_words))" + } > "$file" + echo "$file" +} + +# A description over the 250-character target, carrying no boundary clause and +# no routing target — the exact shape `zoom-out` and `caveman` ship. Built with +# no hyphens so nothing in it reads as a target. +HAND_DESC="$(python3 -c " +prefix = 'Tell the agent to zoom out and give broader context. ' +print(prefix + 'x' * (300 - len(prefix)))")" + +echo "" +echo "--- a hand-invoked skill is exempt from the routing rules, and only those ---" +expect_gate "a hand-invoked skill with a 300-char description and no boundary clause is silent" \ + pass "$(make_hand_invoked_fixture hand-quiet "$HAND_DESC" 10)" +# The control that makes the case above mean something. Same description, same +# body, only the frontmatter flag removed: both findings must appear, or the +# exemption is being credited for silence it did not cause. +expect_gate "control: the SAME description without the flag is over the 250-char target" \ + suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \ + "description is 300 characters" +expect_gate "control: the SAME description without the flag has no boundary clause" \ + suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \ + "has no boundary clause" + +echo "" +echo "--- the carve-out lifts the routing rules ONLY: both size gates still bite ---" +# The description ceiling is not a routing budget: a hand-invoked description is +# still the one line a human reads in the `/` menu, and 400 characters is the +# outlier stop either way. +HAND_OVER_MAX="$(python3 -c " +prefix = 'Tell the agent to zoom out and give broader context. ' +print(prefix + 'x' * (401 - len(prefix)))")" +expect_gate "a hand-invoked description over $DESC_MAX_CHARS chars still FAILS" \ + fail "$(make_hand_invoked_fixture hand-over-max "$HAND_OVER_MAX" 10)" \ + "$DESC_MAX_CHARS-character ceiling" +# The body is loaded on invocation like any other body and competes with the +# caller's live conversation exactly the same way, so neither body tier moves. +expect_gate "a hand-invoked body over $BODY_MAX_WORDS words still FAILS" \ + fail "$(make_hand_invoked_fixture hand-over-body "$HAND_DESC" "$((BODY_MAX_WORDS + 1))")" \ + "$BODY_MAX_WORDS-word ceiling" +expect_gate "a hand-invoked body over $BODY_SUGGEST_WORDS words is still suggested" \ + suggest "$(make_hand_invoked_fixture hand-over-body-suggest "$HAND_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \ + "body is $((BODY_SUGGEST_WORDS + 1)) words" + +echo "" +echo "--- the flag is read as a BOOLEAN, not as any mention of the key ---" +# `disable-model-invocation: false` is the model-invoked case written out +# longhand. Reading the key's presence instead of its value would hand every +# routing exemption to anyone who typed the field at all. +HAND_FALSE="$TMPDIR/hand-false.md" +{ + echo "---" + echo "name: hand-false" + echo "description: $HAND_DESC" + echo "disable-model-invocation: false" + echo "---" + echo "" + echo "Do the thing." +} > "$HAND_FALSE" +expect_gate "disable-model-invocation: false is NOT the carve-out" \ + suggest "$HAND_FALSE" "has no boundary clause" + echo "" echo "--- resolvable boundary targets ---" # Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and -- 2.43.0 From 27a76692b0e3dfee53f47486cf6aedeb8df823a0 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:18 +0000 Subject: [PATCH 60/89] fix(kyberforge): stop the provenance checker skipping check 8 on unparsed input 484357a taught the Contributing parser the bullet form, but a block it still could not parse returned the same empty result as an explicit "(none)", so the checker read "no contributing files" and skipped check 8 rather than reporting that it could not tell. Checks 7 and 8 were consequently dead across the whole git plugin without anything failing. The parser now distinguishes "declared none" from "could not parse", which wakes both checks. Because the parser is duplicated between the skill-audit and agent-audit copies, it is fenced with BEGIN/END markers and a test hashes the two regions so the copies cannot drift apart again silently. Addresses #111. --- .../references/orchestrator-contract.md | 4 + .../references/orchestrator-contract.md | 4 + .../scripts/validate-provenance.sh | 61 ++++++- .../scripts/validate-provenance.sh | 160 +++++++++++++++--- .../scripts/validate-provenance.sh | 61 ++++++- .../scripts/validate-provenance.sh | 160 +++++++++++++++--- tests/test-adr0020-contract.sh | 74 +++++++- 7 files changed, 462 insertions(+), 62 deletions(-) diff --git a/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md b/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md index 7fceb33..954c835 100644 --- a/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md +++ b/plugins/git/.apm/skills/git-branches/references/orchestrator-contract.md @@ -1,3 +1,7 @@ +--- +source_keys: [] +--- + # Orchestrator request contract `git-orchestrate` and other calling agents send this shape. The result shape they parse back is in diff --git a/plugins/git/skills/git-branches/references/orchestrator-contract.md b/plugins/git/skills/git-branches/references/orchestrator-contract.md index 7fceb33..954c835 100644 --- a/plugins/git/skills/git-branches/references/orchestrator-contract.md +++ b/plugins/git/skills/git-branches/references/orchestrator-contract.md @@ -1,3 +1,7 @@ +--- +source_keys: [] +--- + # Orchestrator request contract `git-orchestrate` and other calling agents send this shape. The result shape they parse back is in diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh index 82395c3..a5525d1 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh @@ -130,15 +130,55 @@ 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. - Accepts the inline form and the bullet form; recognising only the - inline one silently skips the contributing-file checks on every - sources.md written the other way. Returns a list of paths with any - trailing parenthetical note stripped; "(none)" returns an empty list - and a slug with no entry returns None. Kept behaviourally identical to - skill-audit's copy, which is where the bug was found. + 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)', @@ -150,15 +190,19 @@ def parse_contributing_files(content, slug): 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] + 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 @@ -177,7 +221,8 @@ def parse_contributing_files(content, slug): entry = strip_note(entry) if entry: files.append(entry) - return files + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== def parse_research_doc(content, slug): pattern = re.compile( diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh index 619ac47..5524605 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh @@ -18,11 +18,16 @@ 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) + 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 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 EOF } @@ -89,16 +94,53 @@ 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. +EMPTY_SOURCE_KEYS_RE = re.compile(r'^\s*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 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 checks 4 and 5 - on every skill using the second: + 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 @@ -107,10 +149,22 @@ def parse_contributing_files(content, slug): - references/a.md (what this source contributed) Returns a list of paths with any trailing parenthetical note stripped. - A "(none)" value returns an empty list; a slug with no Contributing - files entry at all returns None. Note the bullet form's notes may - themselves contain commas, so the list is built per bullet rather than - by splitting the joined value. + 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)', @@ -131,15 +185,15 @@ def parse_contributing_files(content, slug): 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] + 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 - rest = block[cf_m.end():] files = [] - for line in rest.splitlines(): + for line in block[cf_m.end():].splitlines(): line = line.strip() if not line: if files: @@ -153,7 +207,8 @@ def parse_contributing_files(content, slug): entry = strip_note(entry) if entry: files.append(entry) - return files + 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.""" @@ -170,6 +225,36 @@ def parse_research_doc(content, slug): return None return rd_m.group(1).strip() +# 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 + def parse_status(content, slug): """Find the Status value for a given slug H2 in content.""" pattern = re.compile( @@ -321,11 +406,17 @@ if os.path.isdir(refs_dir): 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: @@ -390,24 +481,51 @@ 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): + 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." + ) + else: with open(rd_abs) as f: rd_content = f.read() 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 if rd_abs not in research_docs_seen: - research_docs_seen[rd_abs] = (rd_value, set()) + research_docs_seen[rd_abs] = (rd_path, set()) research_docs_seen[rd_abs][1].add(slug) # --- Check 8: Upstream reverse --- diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh index 82395c3..a5525d1 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh @@ -130,15 +130,55 @@ 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. - Accepts the inline form and the bullet form; recognising only the - inline one silently skips the contributing-file checks on every - sources.md written the other way. Returns a list of paths with any - trailing parenthetical note stripped; "(none)" returns an empty list - and a slug with no entry returns None. Kept behaviourally identical to - skill-audit's copy, which is where the bug was found. + 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)', @@ -150,15 +190,19 @@ def parse_contributing_files(content, slug): 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] + 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 @@ -177,7 +221,8 @@ def parse_contributing_files(content, slug): entry = strip_note(entry) if entry: files.append(entry) - return files + return files or None +# ===== END SHARED CONTRIBUTING-FILES PARSER ===== def parse_research_doc(content, slug): pattern = re.compile( diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh index 619ac47..5524605 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh @@ -18,11 +18,16 @@ 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) + 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 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 EOF } @@ -89,16 +94,53 @@ 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. +EMPTY_SOURCE_KEYS_RE = re.compile(r'^\s*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 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 checks 4 and 5 - on every skill using the second: + 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 @@ -107,10 +149,22 @@ def parse_contributing_files(content, slug): - references/a.md (what this source contributed) Returns a list of paths with any trailing parenthetical note stripped. - A "(none)" value returns an empty list; a slug with no Contributing - files entry at all returns None. Note the bullet form's notes may - themselves contain commas, so the list is built per bullet rather than - by splitting the joined value. + 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)', @@ -131,15 +185,15 @@ def parse_contributing_files(content, slug): 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] + 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 - rest = block[cf_m.end():] files = [] - for line in rest.splitlines(): + for line in block[cf_m.end():].splitlines(): line = line.strip() if not line: if files: @@ -153,7 +207,8 @@ def parse_contributing_files(content, slug): entry = strip_note(entry) if entry: files.append(entry) - return files + 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.""" @@ -170,6 +225,36 @@ def parse_research_doc(content, slug): return None return rd_m.group(1).strip() +# 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 + def parse_status(content, slug): """Find the Status value for a given slug H2 in content.""" pattern = re.compile( @@ -321,11 +406,17 @@ if os.path.isdir(refs_dir): 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: @@ -390,24 +481,51 @@ 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): + 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." + ) + else: with open(rd_abs) as f: rd_content = f.read() 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 if rd_abs not in research_docs_seen: - research_docs_seen[rd_abs] = (rd_value, set()) + research_docs_seen[rd_abs] = (rd_path, set()) research_docs_seen[rd_abs][1].add(slug) # --- Check 8: Upstream reverse --- diff --git a/tests/test-adr0020-contract.sh b/tests/test-adr0020-contract.sh index daea839..1a2dc36 100755 --- a/tests/test-adr0020-contract.sh +++ b/tests/test-adr0020-contract.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash -# Regression test for the three STRUCTURAL claims the ADR-0020 gate family makes -# about itself. None of them was pinned anywhere before this file, and each one -# fails silently — which is the whole reason they need a test rather than a -# comment: +# Regression test for the STRUCTURAL claims the ADR-0020 gate family makes about +# itself. None of them was pinned anywhere before this file, and each one fails +# silently — which is the whole reason they need a test rather than a comment: # # 1. "ONE resolver, embedded VERBATIM in three scripts." The block between the # BEGIN/END markers is copied, not imported, because a cache-installed @@ -11,6 +10,10 @@ # one-line edit to a single copy is invisible: every constant-agreement # assertion in tests/test-skill-size-check.sh still passes, because the # CONSTANTS are not what drifted. +# 1b. The same claim, one directory over, for the Contributing-files parser +# embedded in both validate-provenance.sh copies. That one was worse: the +# agent-audit copy's docstring ASSERTED it was kept behaviourally identical +# to skill-audit's, and the two had already drifted. # 2. Both interpreter preflights, in all three scripts. python3 and PyYAML are # declared HARD dependencies precisely so a missing one cannot turn into a # vacuous pass, and the two are checked separately so the message names the @@ -93,6 +96,69 @@ else fi fi +# --------------------------------------------------------------------------- +# 1b. The shared Contributing-files parser is byte-identical in both copies +# --------------------------------------------------------------------------- +# Same defect class, one directory over. parse_contributing_files() is embedded +# in both validate-provenance.sh copies for the same reason the resolver is +# embedded three times, and until this assertion existed the agent-audit copy's +# docstring merely CLAIMED it was "kept behaviourally identical to skill-audit's +# copy" — an invariant nothing checked, and the two had already drifted into +# different spellings of the bullet loop. The parser decides whether checks 4, +# 5 and 8 run at all, so a one-sided edit disables a check in one script while +# every other test stays green. +echo "" +echo "--- the shared Contributing-files parser is byte-identical in both validate-provenance.sh copies ---" + +CF_BEGIN='# ===== BEGIN SHARED CONTRIBUTING-FILES PARSER =====' +CF_END='# ===== END SHARED CONTRIBUTING-FILES PARSER =====' +SKILL_PROV="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh" +AGENT_PROV="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh" + +CF_MARKERS_OK=true +for f in "$SKILL_PROV" "$AGENT_PROV"; do + if [[ ! -f "$f" ]]; then + fail "script not found: $f" + CF_MARKERS_OK=false + continue + fi + b="$(grep -cFx "$CF_BEGIN" "$f" || true)" + e="$(grep -cFx "$CF_END" "$f" || true)" + if [[ "$b" == "1" && "$e" == "1" ]]; then + pass "${f#"$REPO_ROOT/"} carries exactly one BEGIN and one END parser marker" + else + fail "${f#"$REPO_ROOT/"} has $b BEGIN and $e END parser markers, expected 1 and 1" + CF_MARKERS_OK=false + fi +done + +if ! $CF_MARKERS_OK; then + fail "skipping the parser byte-identity comparison — the marker pairs are not well-formed, so any extraction would measure the wrong span" +else + CF_HASHES=() + CF_LINECOUNTS=() + for f in "$SKILL_PROV" "$AGENT_PROV"; do + out="$TMPDIR_T/cfblock-$(echo "$f" | md5sum | cut -c1-8).txt" + sed -n "/^${CF_BEGIN}\$/,/^${CF_END}\$/p" "$f" > "$out" + CF_HASHES+=("$(md5sum < "$out" | cut -d' ' -f1)") + CF_LINECOUNTS+=("$(wc -l < "$out" | tr -d ' ')") + done + if [[ "${CF_HASHES[0]}" == "${CF_HASHES[1]}" ]]; then + pass "both copies hash to ${CF_HASHES[0]} (${CF_LINECOUNTS[0]} lines) — agreement by construction, not by coincidence" + else + fail "the shared Contributing-files parser has DRIFTED: skill-audit=${CF_HASHES[0]} (${CF_LINECOUNTS[0]} lines), agent-audit=${CF_HASHES[1]} (${CF_LINECOUNTS[1]} lines). Edit one copy, then paste it over the other." + fi + # Two identical EMPTY spans would hash equal and assert nothing, exactly as + # for the resolver above. The parser block is ~93 lines; 40 is a floor low + # enough never to need maintenance and high enough that a gutted block — or + # one reduced to its docstring — cannot sneak past. + if [[ "${CF_LINECOUNTS[0]}" -gt 40 ]]; then + pass "the extracted parser block is ${CF_LINECOUNTS[0]} lines — the comparison is over real content, not an empty span" + else + fail "the extracted parser block is only ${CF_LINECOUNTS[0]} lines — two identical empty spans would compare equal and assert nothing" + fi +fi + # --------------------------------------------------------------------------- # 2. Both interpreter preflights, in all three scripts # --------------------------------------------------------------------------- -- 2.43.0 From b07d54ad7a6b4a59263f0530da9cbf4577bfe537 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:26 +0000 Subject: [PATCH 61/89] fix(lint): correct four Vale behaviours the skills described wrongly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these would send a user down a path Vale does not support: Core options placed under a glob header are not scoped to that glob — Vale rejects them with E201, so the guidance to nest them produced a config that will not load. The built-in `Vale` style is compiled in, but Vale still requires StylesPath to exist on disk before it will run, so the "no StylesPath needed" shortcut fails. The MDX guidance was inverted: under `[formats] mdx = md` the mapping is what makes MDX lint at all, and it needs the mdx2vast prerequisite that was never mentioned. And a spelling rule's `ignore` paths resolve against StylesPath, not against the rule file's own directory, so the documented relative paths silently matched nothing. --- plugins/lint/.apm/skills/vale-config/SKILL.md | 6 +- .../references/configuration-reference.md | 6 ++ .../skills/vale-config/references/sources.md | 2 +- plugins/lint/.apm/skills/vale-run/SKILL.md | 13 ++- .../skills/vale-run/references/sources.md | 8 ++ .../vale-run/references/troubleshooting.md | 91 ++++++++++++++++++- plugins/lint/skills/vale-config/SKILL.md | 6 +- .../references/configuration-reference.md | 6 ++ .../skills/vale-config/references/sources.md | 2 +- plugins/lint/skills/vale-run/SKILL.md | 13 ++- .../skills/vale-run/references/sources.md | 8 ++ .../vale-run/references/troubleshooting.md | 91 ++++++++++++++++++- 12 files changed, 224 insertions(+), 28 deletions(-) diff --git a/plugins/lint/.apm/skills/vale-config/SKILL.md b/plugins/lint/.apm/skills/vale-config/SKILL.md index 352daeb..e29c541 100644 --- a/plugins/lint/.apm/skills/vale-config/SKILL.md +++ b/plugins/lint/.apm/skills/vale-config/SKILL.md @@ -8,7 +8,7 @@ description: > metadata: category: lint - version: "0.1.1" + version: "0.1.2" source_keys: - context7-websites-vale-sh - house-vale-3-15-2-repro @@ -19,7 +19,7 @@ metadata: - A style in `BasedOnStyles` that is neither built-in nor a directory under `StylesPath` fails hard, not silently: `E100 [loadStyles]`, exit 2, nothing linted. - `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. - Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. -- `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. +- `.vale.ini` order is enforced, not stylistic: put core settings first, then `[formats]`, then glob sections. A core setting (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) written below a `[glob]` header is a hard error — `E201 ... 'StylesPath' is a core option; it should be defined above any syntax-specific options`, exit 2, nothing linted. `Packages` is the exception, and the worse one: below a glob header it is accepted with no error, then ignored — `vale sync` reports `Synced 0 package(s)` and downloads nothing. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -34,7 +34,7 @@ metadata: [*.md] BasedOnStyles = Vale ``` - `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`) — no download needed, it always works. + `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`): no `Packages` entry and no `vale sync`. It still needs the `StylesPath` directory to exist — declare `StylesPath = styles` without creating `styles/` and even a `Vale`-only config dies with `E201 ... The path '...' does not exist`, exit 2. That is why the previous step creates the directory. - [ ] **Add third-party styles** (optional) by declaring them in `Packages`, then activating them in the same or another glob's `BasedOnStyles`: ```ini Packages = Google, write-good diff --git a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md index aec5fa7..5448ab6 100644 --- a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md @@ -25,6 +25,10 @@ Map an unrecognized extension onto a supported one so Vale lints it with the rig mdx = md ``` +`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Install it with `npm install -g mdx2vast` or take the mapping. + +The choice also decides the inline-suppression syntax, and it is inverted between the two: mapped to `md`, `.mdx` takes Markdown's `<!-- vale off -->`; native, it takes `{/* vale off */}`. `vale-run`'s `references/troubleshooting.md` carries the verified matrix. + ## Vocabularies Reference a named vocabulary (a folder of accept/reject word lists under `StylesPath`) via `Vocab`, then apply styles per glob: @@ -89,6 +93,8 @@ Only *package* styles need fetching. A style whose YAML rule files are already c | Style in `Packages` and synced, but in no glob's `BasedOnStyles` | 0 findings, exit 0 — downloads, never lints, indistinguishable from a clean run | | `BasedOnStyles` names an *empty* directory under `StylesPath` | 0 findings, exit 0 — loads and lints nothing; `vale sync` never produces this state | | Built-in `Vale`, or a style's YAML committed under `StylesPath` | lints immediately, no `Packages` entry, no sync | +| Core option (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) below a `[glob]` header | `E201 Invalid value` — `'X' is a core option; it should be defined above any syntax-specific options ([...])`, exit 2 | +| `Packages` below a `[glob]` header | no error, exit unaffected — parsed as a per-glob rule toggle (`SChecks: {"*.md": {"Packages": false}}` in `ls-config`), so `vale sync` reports `Synced 0 package(s)` and downloads nothing | ## Frontmatter Scopes diff --git a/plugins/lint/.apm/skills/vale-config/references/sources.md b/plugins/lint/.apm/skills/vale-config/references/sources.md index a983a59..c4a918e 100644 --- a/plugins/lint/.apm/skills/vale-config/references/sources.md +++ b/plugins/lint/.apm/skills/vale-config/references/sources.md @@ -11,7 +11,7 @@ ## house-vale-3-15-2-repro - **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) -- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, the `E201` / exit-2 failure when a core option is written below a `[glob]` header (with `Packages` as the silent exception), and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. - **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) - **Contributing files:** SKILL.md, references/configuration-reference.md - **Status:** `extracted` diff --git a/plugins/lint/.apm/skills/vale-run/SKILL.md b/plugins/lint/.apm/skills/vale-run/SKILL.md index 2ceb78d..5baae10 100644 --- a/plugins/lint/.apm/skills/vale-run/SKILL.md +++ b/plugins/lint/.apm/skills/vale-run/SKILL.md @@ -6,16 +6,17 @@ description: > as in "lint the docs", "check prose style", or "why is CI failing on the docs check". Not setting up Vale config or styles -> `vale-config`. metadata: - version: "0.1.2" + version: "0.1.3" category: lint source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas - Vale's exit code keys off `error`-level alerts only — `warning` and `suggestion` alerts print and still exit `0`, and `MinAlertLevel`/`--minAlertLevel` filter what is displayed, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the most common way a Vale gate silently passes everything. -- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. +- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a `text.frontmatter.<key>` scope that silently stops matching multi-line values (`>` folded scalars, plain continuation lines and quoted multi-line scalars all go unmatched; a `|` literal block scalar still works) — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. ## Running vale @@ -34,20 +35,22 @@ Key flags: | `--no-exit` | Suppresses the nonzero exit that `error`-level alerts would otherwise cause; a no-op when no rule is `error`-level. Use in CI stages that should surface lint output without hard-failing the build. | | `--ignore-syntax` | Treats input as plain text, skipping format-aware parsing — use when a file's syntax-aware parser produces noisy or wrong results. | -`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` to confirm what actually loaded before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. +`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. `ls-config` resolves config files, styles and `StylesPath` search paths; it never enumerates rules, so it cannot tell you a given rule is live. Prefer `--output=JSON` whenever the caller (a script, a CI step, another agent) needs to act on individual alerts rather than just get a pass/fail signal — `CLI` and `line` are for humans reading the terminal. ## Fixing false positives -Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md` for your file's format: the markup is format-specific, and the wrong form suppresses nothing while Vale reports no error — the Markdown HTML-comment form is inert in an MDX file. +Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md`: the form follows the parser the config picks, not the file extension, and the wrong form suppresses nothing while Vale reports no error. + +`.mdx` is the trap — the two parsers take opposite forms, so read `.vale.ini` first. Under `[formats] mdx = md` (what `vale-config` recommends) it is Markdown: `<!-- vale off -->` suppresses, `{/* vale off */}` does not. Without that mapping Vale takes the native MDX path, which needs the external `mdx2vast` binary (`npm install -g mdx2vast`); missing, the whole invocation dies — `E100 [lintMDX] ... mdx2vast not found`, exit 2 — leaving every other file in the run unlinted too. Scope the fix as narrowly as possible, in this order: 1. **Mentioning banned phrasing rather than using it**: wrap it in backticks or a fenced code block. Vale skips code spans and fences, so no suppression is needed at all. Try this before any suppression markup. 2. **One-off**: inline-suppress the specific text run with the format's `vale off`/`vale on` markup. 3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (in Markdown, e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. -4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression. +4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression — and put the listed file at the `StylesPath` root, because an `ignore` path that resolves nowhere is a silent no-op (`references/troubleshooting.md`). Never disable a rule project-wide to fix one false positive — editing `.vale.ini`/`BasedOnStyles` is vale-config's job, and it silences the rule everywhere, not just the false-positive case. diff --git a/plugins/lint/.apm/skills/vale-run/references/sources.md b/plugins/lint/.apm/skills/vale-run/references/sources.md index 279ef95..196d43a 100644 --- a/plugins/lint/.apm/skills/vale-run/references/sources.md +++ b/plugins/lint/.apm/skills/vale-run/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/troubleshooting.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing or documents it wrongly: `.mdx` has no built-in support and needs either `[formats] mdx = md` or an external `mdx2vast` binary (absent, the whole invocation exits 2 with `E100 [lintMDX]`), the inline-suppression form inverts between those two configurations, the `spelling` check's `ignore` paths resolve against `StylesPath` or the working directory but never against the rule file's own directory and fail silently when they resolve nowhere, `ls-config` reports styles and paths but never rules, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/troubleshooting.md +- **Status:** `extracted` diff --git a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md index c7b601a..a00a777 100644 --- a/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md +++ b/plugins/lint/.apm/skills/vale-run/references/troubleshooting.md @@ -1,6 +1,7 @@ --- source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- # Vale troubleshooting reference @@ -9,19 +10,68 @@ source_keys: `vale ls-config` prints the fully-resolved, currently active configuration as JSON. Check that before rereading `.vale.ini` — what is written in the config file is not necessarily what is -active, and the resolved output is the fastest way to see which styles and rules a run actually -loaded. +active, and the resolved output is the fastest way to see which config files, styles and +`StylesPath` directories a run actually loaded. + +It stops at styles. It does not enumerate rules, and neither does any other Vale 3.15.2 +subcommand — `ls-config`, `ls-dirs`, `ls-vars` and `ls-metrics` were each checked and none +names the rule. Against a config +whose custom rule was demonstrably firing on the target file, `ls-config` reported +`"SBaseStyles": {"*.md": ["MyStyle"]}` and the two `StylesPath` search paths, but +`"Checks": null`, `"SChecks": {"*.md": {}}` and `"RuleToLevel": {}` — the firing rule's own name +appeared nowhere in the output. So `ls-config` answers "is this style loaded, and from where", +not "is this rule live". For the latter, run Vale over a small fixture that should trigger the +rule and see whether it alerts. ## Inline suppression syntax by format -Markdown uses HTML comments — the MDX `{/* */}` form does not suppress anything in a plain `.md` file: +### Prerequisite: `.mdx` needs a decision before it lints at all + +Vale 3.15.2 has no built-in MDX support. If `.vale.ini` does **not** map the extension, Vale takes +the native MDX path and shells out to an external `mdx2vast` binary. Without it on `PATH` the run +dies before linting anything: + +``` +$ vale . # doc.md and doc.mdx both present, no [formats] mapping +E100 [lintMDX] Runtime error + +mdx2vast not found + +Execution stopped with code 1. +$ echo $? +2 +``` + +That is the whole invocation, not just the `.mdx` file — the `.md` alongside it produced no output +either. Fix it one of two ways, and the choice is not cosmetic because it also decides the +suppression syntax: + +| `.vale.ini` | Prerequisite | Parser | Suppression form that works | +|---|---|---|---| +| `[formats]` maps `mdx = md` (what `vale-config` recommends) | none | Markdown | `<!-- vale off -->` | +| no `mdx` mapping (native MDX) | `npm install -g mdx2vast` | MDX | `{/* vale off */}` | + +Key the markup to that config row, never to the file extension. Verified against Vale 3.15.2, same +three fixtures under each config: + +| File | Mapped `mdx = md` | Native MDX (`mdx2vast` installed) | +|---|---|---| +| no suppression (control) | alert, exit 1 | alert, exit 1 | +| `<!-- vale off -->` | suppressed, exit 0 | `E100 ... failed to parse MDX: Unexpected character` `` `!` ``, exit 2 | +| `{/* vale off */}` | **not suppressed**, exit 1 | suppressed, exit 0 | + +Under the mapping the JSX comment is worse than inert: it is linted as prose, so +`{/* vale Vale.Repetition = NO */}` produced three alerts where the un-suppressed file produced +one — its own markup tripped the rule twice more. + +Markdown, and `.mdx` mapped onto it — HTML comments: ```markdown <!-- vale off --> This text will be ignored. <!-- vale on --> ``` -MDX: +Native MDX only (no `[formats]` mapping, `mdx2vast` on `PATH`): ```mdx {/* vale off */} This text will be ignored. @@ -46,7 +96,8 @@ This is some text ACT test <!-- vale Style.Redundancy["ACT test","OTHER"] = YES --> ``` -MDX: +Native MDX only — under `[formats] mdx = md` an `.mdx` file takes the Markdown form above, and +this one silences nothing: ```mdx {/* vale Style.Redundancy["ACT test","OTHER"] = NO */} This is some text ACT test @@ -66,6 +117,36 @@ ignore: - ignore2.txt ``` +**Where the file goes, and why a wrong answer is invisible.** Each entry resolves against the +`StylesPath` root, or against the working directory `vale` is invoked from. It does **not** resolve +against the rule file's own directory — which is the natural reading of the YAML above, since the +path sits inside the rule, and it is wrong. Verified against Vale 3.15.2 across four fresh trees, +each with the same rule and the same unknown word: + +| Where `ignore1.txt` was placed | Result | +|---|---| +| `<StylesPath>/ignore1.txt` | word ignored, exit 0 | +| `./ignore1.txt` in the directory `vale` runs from | word ignored, exit 0 | +| `<StylesPath>/<Style>/ignore1.txt`, beside the rule | word still flagged, exit 1 | +| file absent entirely | word still flagged, exit 1 | + +The two failing rows emit no warning, no error, and no diagnostic of any kind: the output is +byte-identical to the same rule with the `ignore` key deleted. A misplaced ignore list looks exactly +like a list that was read and did not contain the word. + +Prefer the `StylesPath` root. The run-directory form is the fragile one — move the invocation and it +silently stops working. Same tree, same file, only the working directory changed: + +``` +$ cd project && vale doc.md # ignore1.txt at project root +✔ 0 errors, 0 warnings and 0 suggestions in 1 file. # exit 0 + +$ cd /elsewhere && vale --config=project/.vale.ini project/doc.md + 1:5 error Did you really mean 'zzqwidget'? MyStyle.Spell # exit 1 +``` + +The `StylesPath` copy survives that move; the run-directory copy does not. + ## Plain-text fallback If a file's syntax-aware parsing produces noisy or incorrect results (an unsupported or malformed format), rerun with `--ignore-syntax` to treat it as plain text instead of relying on the format-specific parser. diff --git a/plugins/lint/skills/vale-config/SKILL.md b/plugins/lint/skills/vale-config/SKILL.md index 352daeb..e29c541 100644 --- a/plugins/lint/skills/vale-config/SKILL.md +++ b/plugins/lint/skills/vale-config/SKILL.md @@ -8,7 +8,7 @@ description: > metadata: category: lint - version: "0.1.1" + version: "0.1.2" source_keys: - context7-websites-vale-sh - house-vale-3-15-2-repro @@ -19,7 +19,7 @@ metadata: - A style in `BasedOnStyles` that is neither built-in nor a directory under `StylesPath` fails hard, not silently: `E100 [loadStyles]`, exit 2, nothing linted. - `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. - Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. -- `.vale.ini` is order-sensitive: core settings first, then `[formats]`, then glob sections. Anything written below a glob header applies only to files matching that glob. +- `.vale.ini` order is enforced, not stylistic: put core settings first, then `[formats]`, then glob sections. A core setting (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) written below a `[glob]` header is a hard error — `E201 ... 'StylesPath' is a core option; it should be defined above any syntax-specific options`, exit 2, nothing linted. `Packages` is the exception, and the worse one: below a glob header it is accepted with no error, then ignored — `vale sync` reports `Synced 0 package(s)` and downloads nothing. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -34,7 +34,7 @@ metadata: [*.md] BasedOnStyles = Vale ``` - `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`) — no download needed, it always works. + `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`): no `Packages` entry and no `vale sync`. It still needs the `StylesPath` directory to exist — declare `StylesPath = styles` without creating `styles/` and even a `Vale`-only config dies with `E201 ... The path '...' does not exist`, exit 2. That is why the previous step creates the directory. - [ ] **Add third-party styles** (optional) by declaring them in `Packages`, then activating them in the same or another glob's `BasedOnStyles`: ```ini Packages = Google, write-good diff --git a/plugins/lint/skills/vale-config/references/configuration-reference.md b/plugins/lint/skills/vale-config/references/configuration-reference.md index aec5fa7..5448ab6 100644 --- a/plugins/lint/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/skills/vale-config/references/configuration-reference.md @@ -25,6 +25,10 @@ Map an unrecognized extension onto a supported one so Vale lints it with the rig mdx = md ``` +`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Install it with `npm install -g mdx2vast` or take the mapping. + +The choice also decides the inline-suppression syntax, and it is inverted between the two: mapped to `md`, `.mdx` takes Markdown's `<!-- vale off -->`; native, it takes `{/* vale off */}`. `vale-run`'s `references/troubleshooting.md` carries the verified matrix. + ## Vocabularies Reference a named vocabulary (a folder of accept/reject word lists under `StylesPath`) via `Vocab`, then apply styles per glob: @@ -89,6 +93,8 @@ Only *package* styles need fetching. A style whose YAML rule files are already c | Style in `Packages` and synced, but in no glob's `BasedOnStyles` | 0 findings, exit 0 — downloads, never lints, indistinguishable from a clean run | | `BasedOnStyles` names an *empty* directory under `StylesPath` | 0 findings, exit 0 — loads and lints nothing; `vale sync` never produces this state | | Built-in `Vale`, or a style's YAML committed under `StylesPath` | lints immediately, no `Packages` entry, no sync | +| Core option (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) below a `[glob]` header | `E201 Invalid value` — `'X' is a core option; it should be defined above any syntax-specific options ([...])`, exit 2 | +| `Packages` below a `[glob]` header | no error, exit unaffected — parsed as a per-glob rule toggle (`SChecks: {"*.md": {"Packages": false}}` in `ls-config`), so `vale sync` reports `Synced 0 package(s)` and downloads nothing | ## Frontmatter Scopes diff --git a/plugins/lint/skills/vale-config/references/sources.md b/plugins/lint/skills/vale-config/references/sources.md index a983a59..c4a918e 100644 --- a/plugins/lint/skills/vale-config/references/sources.md +++ b/plugins/lint/skills/vale-config/references/sources.md @@ -11,7 +11,7 @@ ## house-vale-3-15-2-repro - **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) -- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing: the `E100 [loadStyles]` / exit-2 failure for a `BasedOnStyles` name absent from `StylesPath`, `vale sync` reporting `Synced 0 package(s)` for a name not declared in `Packages`, the `E201` / exit-2 failure when the `StylesPath` directory does not exist, the exit-0 no-op of an empty style directory, the `E201` / exit-2 failure when a core option is written below a `[glob]` header (with `Packages` as the silent exception), and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. - **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) - **Contributing files:** SKILL.md, references/configuration-reference.md - **Status:** `extracted` diff --git a/plugins/lint/skills/vale-run/SKILL.md b/plugins/lint/skills/vale-run/SKILL.md index 2ceb78d..5baae10 100644 --- a/plugins/lint/skills/vale-run/SKILL.md +++ b/plugins/lint/skills/vale-run/SKILL.md @@ -6,16 +6,17 @@ description: > as in "lint the docs", "check prose style", or "why is CI failing on the docs check". Not setting up Vale config or styles -> `vale-config`. metadata: - version: "0.1.2" + version: "0.1.3" category: lint source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- ## Gotchas - Vale's exit code keys off `error`-level alerts only — `warning` and `suggestion` alerts print and still exit `0`, and `MinAlertLevel`/`--minAlertLevel` filter what is displayed, never the exit code — no flag makes warnings fail. A rule that must gate CI or a commit hook has to be `level: error`. This is the most common way a Vale gate silently passes everything. -- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a scope that silently stops matching multi-line YAML block-scalar frontmatter — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. +- Check whether the target repo documents its own `vale` wrapper script (README, CONTRIBUTING, pre-commit config, `scripts/`) before calling the binary. Some projects wrap `vale` to work around real bugs — e.g. a `text.frontmatter.<key>` scope that silently stops matching multi-line values (`>` folded scalars, plain continuation lines and quoted multi-line scalars all go unmatched; a `|` literal block scalar still works) — so bare `vale` skips whatever the wrapper fixes. Invoke the documented wrapper with the same arguments. ## Running vale @@ -34,20 +35,22 @@ Key flags: | `--no-exit` | Suppresses the nonzero exit that `error`-level alerts would otherwise cause; a no-op when no rule is `error`-level. Use in CI stages that should surface lint output without hard-failing the build. | | `--ignore-syntax` | Treats input as plain text, skipping format-aware parsing — use when a file's syntax-aware parser produces noisy or wrong results. | -`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` to confirm what actually loaded before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. +`vale sync` downloads the packages/styles declared in `.vale.ini` — that's a one-time-per-change setup step (vale-config's territory), not part of a normal lint run. If a run behaves as though no styles are active, check `vale ls-config` before concluding it is a sync problem — an unrun `vale sync` is the usual cause, and not a `vale-run` problem. `ls-config` resolves config files, styles and `StylesPath` search paths; it never enumerates rules, so it cannot tell you a given rule is live. Prefer `--output=JSON` whenever the caller (a script, a CI step, another agent) needs to act on individual alerts rather than just get a pass/fail signal — `CLI` and `line` are for humans reading the terminal. ## Fixing false positives -Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md` for your file's format: the markup is format-specific, and the wrong form suppresses nothing while Vale reports no error — the Markdown HTML-comment form is inert in an MDX file. +Before writing any inline suppression markup (steps 2 and 3 below), read `references/troubleshooting.md`: the form follows the parser the config picks, not the file extension, and the wrong form suppresses nothing while Vale reports no error. + +`.mdx` is the trap — the two parsers take opposite forms, so read `.vale.ini` first. Under `[formats] mdx = md` (what `vale-config` recommends) it is Markdown: `<!-- vale off -->` suppresses, `{/* vale off */}` does not. Without that mapping Vale takes the native MDX path, which needs the external `mdx2vast` binary (`npm install -g mdx2vast`); missing, the whole invocation dies — `E100 [lintMDX] ... mdx2vast not found`, exit 2 — leaving every other file in the run unlinted too. Scope the fix as narrowly as possible, in this order: 1. **Mentioning banned phrasing rather than using it**: wrap it in backticks or a fenced code block. Vale skips code spans and fences, so no suppression is needed at all. Try this before any suppression markup. 2. **One-off**: inline-suppress the specific text run with the format's `vale off`/`vale on` markup. 3. **Recurring known-exception string, one rule**: disable that specific rule for that specific match inline (in Markdown, e.g. `<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->` ... `= YES`), rather than the whole rule. -4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression. +4. **Known project term failing spell check**: add it to the style's `ignore` list, not an inline suppression — and put the listed file at the `StylesPath` root, because an `ignore` path that resolves nowhere is a silent no-op (`references/troubleshooting.md`). Never disable a rule project-wide to fix one false positive — editing `.vale.ini`/`BasedOnStyles` is vale-config's job, and it silences the rule everywhere, not just the false-positive case. diff --git a/plugins/lint/skills/vale-run/references/sources.md b/plugins/lint/skills/vale-run/references/sources.md index 279ef95..196d43a 100644 --- a/plugins/lint/skills/vale-run/references/sources.md +++ b/plugins/lint/skills/vale-run/references/sources.md @@ -7,3 +7,11 @@ - **Research doc:** plugins/lint/docs/research/docs/vale/sources.md - **Contributing files:** SKILL.md, references/troubleshooting.md - **Status:** `extracted` + +## house-vale-3-15-2-repro + +- **URL:** (house-verified — reproduced locally against the `vale` binary, not an external source) +- **Description:** Behaviour of Vale 3.15.2 established by running it against purpose-built fixtures in this repo, where vale.sh documents nothing or documents it wrongly: `.mdx` has no built-in support and needs either `[formats] mdx = md` or an external `mdx2vast` binary (absent, the whole invocation exits 2 with `E100 [lintMDX]`), the inline-suppression form inverts between those two configurations, the `spelling` check's `ignore` paths resolve against `StylesPath` or the working directory but never against the rule file's own directory and fail silently when they resolve nowhere, `ls-config` reports styles and paths but never rules, and the `text.frontmatter.<key>` scope matrix across multi-line YAML forms. +- **Research doc:** none — house-verified reproduction, not part of the plugin's research corpus (no `plugins/lint/docs/research/` topic file backs this entry) +- **Contributing files:** SKILL.md, references/troubleshooting.md +- **Status:** `extracted` diff --git a/plugins/lint/skills/vale-run/references/troubleshooting.md b/plugins/lint/skills/vale-run/references/troubleshooting.md index c7b601a..a00a777 100644 --- a/plugins/lint/skills/vale-run/references/troubleshooting.md +++ b/plugins/lint/skills/vale-run/references/troubleshooting.md @@ -1,6 +1,7 @@ --- source_keys: - context7-websites-vale-sh + - house-vale-3-15-2-repro --- # Vale troubleshooting reference @@ -9,19 +10,68 @@ source_keys: `vale ls-config` prints the fully-resolved, currently active configuration as JSON. Check that before rereading `.vale.ini` — what is written in the config file is not necessarily what is -active, and the resolved output is the fastest way to see which styles and rules a run actually -loaded. +active, and the resolved output is the fastest way to see which config files, styles and +`StylesPath` directories a run actually loaded. + +It stops at styles. It does not enumerate rules, and neither does any other Vale 3.15.2 +subcommand — `ls-config`, `ls-dirs`, `ls-vars` and `ls-metrics` were each checked and none +names the rule. Against a config +whose custom rule was demonstrably firing on the target file, `ls-config` reported +`"SBaseStyles": {"*.md": ["MyStyle"]}` and the two `StylesPath` search paths, but +`"Checks": null`, `"SChecks": {"*.md": {}}` and `"RuleToLevel": {}` — the firing rule's own name +appeared nowhere in the output. So `ls-config` answers "is this style loaded, and from where", +not "is this rule live". For the latter, run Vale over a small fixture that should trigger the +rule and see whether it alerts. ## Inline suppression syntax by format -Markdown uses HTML comments — the MDX `{/* */}` form does not suppress anything in a plain `.md` file: +### Prerequisite: `.mdx` needs a decision before it lints at all + +Vale 3.15.2 has no built-in MDX support. If `.vale.ini` does **not** map the extension, Vale takes +the native MDX path and shells out to an external `mdx2vast` binary. Without it on `PATH` the run +dies before linting anything: + +``` +$ vale . # doc.md and doc.mdx both present, no [formats] mapping +E100 [lintMDX] Runtime error + +mdx2vast not found + +Execution stopped with code 1. +$ echo $? +2 +``` + +That is the whole invocation, not just the `.mdx` file — the `.md` alongside it produced no output +either. Fix it one of two ways, and the choice is not cosmetic because it also decides the +suppression syntax: + +| `.vale.ini` | Prerequisite | Parser | Suppression form that works | +|---|---|---|---| +| `[formats]` maps `mdx = md` (what `vale-config` recommends) | none | Markdown | `<!-- vale off -->` | +| no `mdx` mapping (native MDX) | `npm install -g mdx2vast` | MDX | `{/* vale off */}` | + +Key the markup to that config row, never to the file extension. Verified against Vale 3.15.2, same +three fixtures under each config: + +| File | Mapped `mdx = md` | Native MDX (`mdx2vast` installed) | +|---|---|---| +| no suppression (control) | alert, exit 1 | alert, exit 1 | +| `<!-- vale off -->` | suppressed, exit 0 | `E100 ... failed to parse MDX: Unexpected character` `` `!` ``, exit 2 | +| `{/* vale off */}` | **not suppressed**, exit 1 | suppressed, exit 0 | + +Under the mapping the JSX comment is worse than inert: it is linted as prose, so +`{/* vale Vale.Repetition = NO */}` produced three alerts where the un-suppressed file produced +one — its own markup tripped the rule twice more. + +Markdown, and `.mdx` mapped onto it — HTML comments: ```markdown <!-- vale off --> This text will be ignored. <!-- vale on --> ``` -MDX: +Native MDX only (no `[formats]` mapping, `mdx2vast` on `PATH`): ```mdx {/* vale off */} This text will be ignored. @@ -46,7 +96,8 @@ This is some text ACT test <!-- vale Style.Redundancy["ACT test","OTHER"] = YES --> ``` -MDX: +Native MDX only — under `[formats] mdx = md` an `.mdx` file takes the Markdown form above, and +this one silences nothing: ```mdx {/* vale Style.Redundancy["ACT test","OTHER"] = NO */} This is some text ACT test @@ -66,6 +117,36 @@ ignore: - ignore2.txt ``` +**Where the file goes, and why a wrong answer is invisible.** Each entry resolves against the +`StylesPath` root, or against the working directory `vale` is invoked from. It does **not** resolve +against the rule file's own directory — which is the natural reading of the YAML above, since the +path sits inside the rule, and it is wrong. Verified against Vale 3.15.2 across four fresh trees, +each with the same rule and the same unknown word: + +| Where `ignore1.txt` was placed | Result | +|---|---| +| `<StylesPath>/ignore1.txt` | word ignored, exit 0 | +| `./ignore1.txt` in the directory `vale` runs from | word ignored, exit 0 | +| `<StylesPath>/<Style>/ignore1.txt`, beside the rule | word still flagged, exit 1 | +| file absent entirely | word still flagged, exit 1 | + +The two failing rows emit no warning, no error, and no diagnostic of any kind: the output is +byte-identical to the same rule with the `ignore` key deleted. A misplaced ignore list looks exactly +like a list that was read and did not contain the word. + +Prefer the `StylesPath` root. The run-directory form is the fragile one — move the invocation and it +silently stops working. Same tree, same file, only the working directory changed: + +``` +$ cd project && vale doc.md # ignore1.txt at project root +✔ 0 errors, 0 warnings and 0 suggestions in 1 file. # exit 0 + +$ cd /elsewhere && vale --config=project/.vale.ini project/doc.md + 1:5 error Did you really mean 'zzqwidget'? MyStyle.Spell # exit 1 +``` + +The `StylesPath` copy survives that move; the run-directory copy does not. + ## Plain-text fallback If a file's syntax-aware parsing produces noisy or incorrect results (an unsupported or malformed format), rerun with `--ignore-syntax` to treat it as plain text instead of relying on the format-specific parser. -- 2.43.0 From 8680adf4c01338e2544f022eb4613b72a44eef1e Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:33 +0000 Subject: [PATCH 62/89] fix(gitea): make gitea-releases executable and correct misleading domain claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitea-releases was the weakest skill in the plugin: no allowed-tools, no owner/repo resolution, and a checkbox list where a dispatch table belongs, so an agent reaching it had to guess both its permissions and its inputs. The id-vs-tag_name trap — deleting by tag name where the API wants the numeric id — is restored as an explicit Gotcha because it destroys the wrong release silently. Elsewhere the `exclusive` flag was documented on the wrong side of the read/write split, and label data from one instance was presented as though it were universal, which invites an agent to assume a taxonomy that does not exist on the target repo. rename_branch was missing from the branch surface. Reference prose and fences are cleaned up in passing. --- .../.apm/skills/gitea-branches/README.md | 13 +++--- .../gitea/.apm/skills/gitea-branches/SKILL.md | 15 ++++--- .../gitea-branches/references/branches.md | 43 ++++++++++++++---- .../gitea-branches/references/commits.md | 9 ++-- .../gitea-branches/references/sources.md | 8 ++-- .../gitea/.apm/skills/gitea-files/README.md | 2 +- .../gitea/.apm/skills/gitea-files/SKILL.md | 12 +++-- .../skills/gitea-files/references/writing.md | 11 +++-- .../gitea/.apm/skills/gitea-issues/README.md | 7 ++- .../gitea-issues/references/enrichments.md | 6 +-- .../skills/gitea-issues/references/issues.md | 14 +++--- .../skills/gitea-issues/references/search.md | 4 +- .../skills/gitea-labels-milestones/README.md | 2 +- .../skills/gitea-labels-milestones/SKILL.md | 2 +- .../references/label-inference.md | 20 +++++---- .../references/labels.md | 33 ++++++++------ .../references/milestones.md | 12 ++--- plugins/gitea/.apm/skills/gitea-prs/README.md | 10 ++++- plugins/gitea/.apm/skills/gitea-prs/SKILL.md | 25 ++++++++--- .../gitea-prs/references/pull-requests.md | 2 +- .../.apm/skills/gitea-releases/README.md | 10 ++++- .../gitea/.apm/skills/gitea-releases/SKILL.md | 44 ++++++++++++++----- .../references/call-signatures.md | 13 +++--- .../gitea-releases/references/sources.md | 2 +- .../.apm/skills/gitea-workflow/README.md | 2 +- .../gitea-workflow/references/skill-index.md | 2 +- plugins/gitea/skills/gitea-branches/README.md | 13 +++--- plugins/gitea/skills/gitea-branches/SKILL.md | 15 ++++--- .../gitea-branches/references/branches.md | 43 ++++++++++++++---- .../gitea-branches/references/commits.md | 9 ++-- .../gitea-branches/references/sources.md | 8 ++-- plugins/gitea/skills/gitea-files/README.md | 2 +- plugins/gitea/skills/gitea-files/SKILL.md | 12 +++-- .../skills/gitea-files/references/writing.md | 11 +++-- plugins/gitea/skills/gitea-issues/README.md | 7 ++- .../gitea-issues/references/enrichments.md | 6 +-- .../skills/gitea-issues/references/issues.md | 14 +++--- .../skills/gitea-issues/references/search.md | 4 +- .../skills/gitea-labels-milestones/README.md | 2 +- .../skills/gitea-labels-milestones/SKILL.md | 2 +- .../references/label-inference.md | 20 +++++---- .../references/labels.md | 33 ++++++++------ .../references/milestones.md | 12 ++--- plugins/gitea/skills/gitea-prs/README.md | 10 ++++- plugins/gitea/skills/gitea-prs/SKILL.md | 25 ++++++++--- .../gitea-prs/references/pull-requests.md | 2 +- plugins/gitea/skills/gitea-releases/README.md | 10 ++++- plugins/gitea/skills/gitea-releases/SKILL.md | 44 ++++++++++++++----- .../references/call-signatures.md | 13 +++--- .../gitea-releases/references/sources.md | 2 +- plugins/gitea/skills/gitea-workflow/README.md | 2 +- .../gitea-workflow/references/skill-index.md | 2 +- 52 files changed, 408 insertions(+), 238 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-branches/README.md b/plugins/gitea/.apm/skills/gitea-branches/README.md index 3a99f72..1b7756e 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/README.md +++ b/plugins/gitea/.apm/skills/gitea-branches/README.md @@ -4,7 +4,7 @@ Manage Gitea repository branches and inspect commit history via the Gitea MCP se ## What it does -This skill handles branch lifecycle operations (list, create, delete) and read-only commit +This skill handles branch lifecycle operations (list, create, rename, delete) and read-only commit history (list commits, get a single commit's full detail) against a Gitea repository. It resolves `owner`/`repo` from the git remote, dispatches to the right MCP tool, and applies safety and pagination conventions specific to Gitea's API (e.g. refusing to delete a protected branch without @@ -18,25 +18,26 @@ Branch references that only exist relative to a pull request — a PR's head or cross-repo fork PR heads in particular — belong to `gitea-prs`; `list_branches` cannot see a fork's head at all. -The skill triggers on phrasings like "list branches", "create a branch", "delete a branch", +The skill triggers on phrasings like "list branches", "create a branch", "rename a branch", "delete a branch", "what commits are on this branch", "show commit <sha>", and "what changed in that commit", even when the user does not say "Gitea", as long as the repo's remote is a Gitea instance. ## Before you start Requires a Gitea MCP server configured with a token that has `write:repository` scope. This is -confirmed for `list_branches`, `create_branch`, and `delete_branch` (Gitea gates reads behind write +confirmed for `list_branches`, `create_branch`, and `delete_branch` (and inferred for +`rename_branch`) (Gitea gates reads behind write scope for repo-scoped operations); `list_commits` and `get_commit` are inferred to need the same scope by analogy, not explicitly confirmed — see `references/commits.md`. Requires a git remote named `origin` pointing at the Gitea instance. ## Usage -``` +```text /gitea-branches ``` -Describe your task: list/create/delete a branch, or list/inspect commits. See `SKILL.md`'s +Describe your task: list/create/rename/delete a branch, or list/inspect commits. See `SKILL.md`'s dispatch table for the full set of recognized invocations. ## Files @@ -44,6 +45,6 @@ dispatch table for the full set of recognized invocations. | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents — dispatch table, gotchas | -| `references/branches.md` | Verified call signatures and mechanics for list/create/delete branch | +| `references/branches.md` | Verified call signatures and mechanics for list/create/rename/delete branch | | `references/commits.md` | Verified call signatures and mechanics for list/get commit | | `references/sources.md` | Research sources backing the branch/commit guidance | diff --git a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md index fc112ff..96af538 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md @@ -2,10 +2,10 @@ name: gitea-branches description: > - Use when listing, creating, or deleting branches in a Gitea repository, or - reading its commit history — even when the user does not say "Gitea". Not a - local working copy's branches -> `git-branches`. Not local history -> - `git-history`. Not a PR's head or base branch -> `gitea-prs`. + Use when listing, creating, renaming, or deleting branches in a Gitea repository, + or reading its commit history — even when the user does not say "Gitea". Not a + local checkout's branches -> `git-branches`. Not local history -> + `git-history`. Not a PR's head or base -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. @@ -17,7 +17,7 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea -allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__gitea__delete_branch mcp__gitea__list_commits mcp__gitea__get_commit +allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__gitea__rename_branch mcp__gitea__delete_branch mcp__gitea__list_commits mcp__gitea__get_commit --- ## Gotchas @@ -42,17 +42,18 @@ git remote get-url origin |---|---| | `/gitea-branches` or `/gitea-branches list` | List branches | | `/gitea-branches create <name> [from <base>]` | Create branch | +| `/gitea-branches rename <name> to <new-name>` | Rename branch | | `/gitea-branches delete <name>` | Delete branch | | `/gitea-branches commits [on <branch>] [touching <path>]` | List commit history | | `/gitea-branches commit <sha>` | Get full detail for one commit | -For branch operations (list/create/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. +For branch operations (list/create/rename/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. For commit operations (list/get), read `references/commits.md`. ## Step 3 — Report For reads: display branches as name + protected flag; display commits as SHA (short), message summary, author, date. -For writes (create/delete): confirm the action taken, the branch name, and (for create) the base it forked from. +For writes (create/rename/delete): confirm the action taken, the branch name, and (for create) the base it forked from or (for rename) the name it had before. For errors: surface the HTTP code and message, applying the 404 gotcha above before reporting "not found" to the user. diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md index ff4e239..d15cb8a 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/branches.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/branches.md @@ -7,10 +7,11 @@ source_keys: # Branch operations -Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch` -at authoring time, not copied from research docs — this is deliberate: research docs are generated -from source code at a point in time and can drift from the server actually deployed. Re-verify -against the live schema if these tools appear to behave differently than documented here. +Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, +not copied from research docs — this is deliberate: research docs are generated from source code at +a point in time and can drift from the server actually deployed. **Last verified against gitea-mcp +v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify against the live schema if the +deployed version differs or these tools behave differently than documented here. ## `list_branches` @@ -21,7 +22,7 @@ against the live schema if these tools appear to behave differently than documen - `per_page` (number, optional, default: `30`) **Call:** -``` +```text list_branches owner: <owner> repo: <repo> ``` @@ -41,7 +42,7 @@ count is less than `per_page`. branch server-side (not necessarily your current local checkout) **Call:** -``` +```text create_branch owner: <owner> repo: <repo> branch: <new-name> old_branch: <source-branch> ``` @@ -53,6 +54,29 @@ top-level request with no working branch context), omit `old_branch` and let it A branch name collision returns `409 Conflict`. +## `rename_branch` + +**Parameters:** +- `owner` (string, required) +- `repo` (string, required) +- `branch` (string, required) — the branch's current name +- `new_name` (string, required) — the name to move it to + +**Call:** +```text +rename_branch owner: <owner> repo: <repo> branch: <current-name> new_name: <new-name> +``` + +A rename moves the ref server-side; it is not a delete-plus-create, and no commit history is +rewritten. What it does to things *pointing at* the old name — open pull requests using it as head or +base, a branch protection rule matching it, CI config, and tracking branches on every other clone — +is **not confirmed** by this skill's sources: the deployed tool describes itself only as "Rename an +existing branch in a repository". Treat a rename of a branch with open PRs or a protection rule as a +change needing verification afterward (`list_branches`, plus `gitea-prs` for the PR side), and +confirm with the user first, exactly as for `delete_branch` below. A collision with an existing +branch name is expected to return `409 Conflict` by analogy with `create_branch`, not separately +confirmed. + ## `delete_branch` **Parameters:** @@ -61,7 +85,7 @@ A branch name collision returns `409 Conflict`. - `branch` (string, required) **Call:** -``` +```text delete_branch owner: <owner> repo: <repo> branch: <name> ``` @@ -73,9 +97,12 @@ protected, every time, regardless of how the request is phrased. ## Token scope -All three — `list_branches`, `create_branch`, `delete_branch` — require `write:repository`. Gitea +`list_branches`, `create_branch` and `delete_branch` all require `write:repository`. Gitea gates reads behind write scope for repo-scoped operations, so `list_branches` needs the same scope as the write operations, not `write:issue` alone. An earlier version of this doc claimed `write:issue` alone was sufficient for `list_branches`, based on empirical testing under a token that held both `write:issue` and `write:repository` simultaneously — that test didn't isolate the variable, so it couldn't actually establish `write:issue` alone as sufficient. + +`rename_branch` is a write on the same repo-scoped surface and is inferred to need `write:repository` +too — inferred by analogy, not separately confirmed. diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md index 12c1d68..552a74a 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/commits.md @@ -8,8 +8,9 @@ source_keys: # Commit operations Read-only commit history, scoped to a repo (optionally to one branch or one path). Call signatures -below were verified live against the deployed `gitea-mcp` server via `ToolSearch` at authoring time, -not copied from research docs, for the same drift-avoidance reason noted in `references/branches.md`. +below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, not copied from +research docs, for the same drift-avoidance reason noted in `references/branches.md`. **Last verified +against gitea-mcp v1.7.0**, as reported by `get_gitea_mcp_server_version`. This domain has no prior skill precedent — it's new coverage added alongside branches because commit history is naturally scoped to a branch (a "what happened on this branch" question), not because it @@ -27,7 +28,7 @@ shares any tool family with branch create/delete. - `per_page` (number, optional, default: `30`, minimum: `1`) **Call:** -``` +```text list_commits owner: <owner> repo: <repo> sha: <branch-or-sha> path: <optional-path> ``` @@ -52,7 +53,7 @@ Paginate per the pagination Gotcha in SKILL.md if you need more than one page of - `sha` (string, required) **Call:** -``` +```text get_commit owner: <owner> repo: <repo> sha: <commit-sha> ``` diff --git a/plugins/gitea/.apm/skills/gitea-branches/references/sources.md b/plugins/gitea/.apm/skills/gitea-branches/references/sources.md index 0341ef5..76fb66e 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-branches/references/sources.md @@ -2,8 +2,8 @@ **Note on call signatures:** per `docs/adr/0011-gitea-skill-deep-modules.md`, the tool parameter signatures in `references/branches.md` and `references/commits.md` were re-verified live via -`ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied -verbatim from `api-reference.md` below. This resolves issue #6 comment #849's root-cause finding +`ToolSearch` against the deployed `gitea-mcp` server — **last verified against v1.7.0**, as reported +by `get_gitea_mcp_server_version` — rather than copied verbatim from `api-reference.md` below. This resolves issue #6 comment #849's root-cause finding that a prior skill was authored from API docs that had drifted from the actual MCP tool schema. The research docs cited here informed gotchas, response shapes, and workflow context, not the parameter lists themselves. @@ -11,7 +11,7 @@ parameter lists themselves. ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. Informed the dispatch table and pagination / 404-may-mean-403 gotchas in SKILL.md, and the list/create/delete branch and list/get commit mechanics (including 409 conflict and default-branch fallback behavior) in references/branches.md and references/commits.md. +- **Description:** Official gitea-mcp repository; operation/*.go source files documenting the MCP tools, their parameters, and CLI flags. Extracted at v1.3.0; the parameter lists carried into this skill are re-verified live against the deployed server, last at v1.7.0. Informed the dispatch table and pagination / 404-may-mean-403 gotchas in SKILL.md, and the list/create/delete branch and list/get commit mechanics (including 409 conflict and default-branch fallback behavior) in references/branches.md and references/commits.md. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/branches.md, references/commits.md - **Status:** `extracted` @@ -19,7 +19,7 @@ parameter lists themselves. ## gitea-mcp-slim-go - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/repo/slim.go -- **Description:** Slim response shape structs from gitea-mcp source; defines exactly which fields the MCP server returns for branches (name, protected, commit_sha) and commits (sha, html_url, created, message, author), and informed get_commit's always-populated guarantee vs. list_commits' conditional fields. +- **Description:** Slim response shape structs from gitea-mcp source, extracted at v1.3.0; defines exactly which fields the MCP server returns for branches (name, protected, commit_sha) and commits (sha, html_url, created, message, author), and informed get_commit's always-populated guarantee vs. list_commits' conditional fields. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** references/branches.md, references/commits.md - **Status:** `extracted` diff --git a/plugins/gitea/.apm/skills/gitea-files/README.md b/plugins/gitea/.apm/skills/gitea-files/README.md index 333e899..3e38b79 100644 --- a/plugins/gitea/.apm/skills/gitea-files/README.md +++ b/plugins/gitea/.apm/skills/gitea-files/README.md @@ -8,7 +8,7 @@ This skill handles file-domain operations within the Gitea integration suite: re ## Usage -``` +```text /gitea-files ``` diff --git a/plugins/gitea/.apm/skills/gitea-files/SKILL.md b/plugins/gitea/.apm/skills/gitea-files/SKILL.md index ace6379..555782e 100644 --- a/plugins/gitea/.apm/skills/gitea-files/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-files/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g - **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. - **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. -- **`content` is base64 both ways.** Encode before a write, decode after a read; `withLines: true` returns numbered lines. +- **`content` is base64 both ways.** Encode before a write, decode after a read. ## Inputs @@ -32,14 +32,12 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Dispatch +Read the reference for the row you land on before making the call. + | Condition | Flow | Reference | |---|---|---| -| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` | -| Create, update, or delete a file | Write | `references/writing.md` | - -If the request only inspects repository contents, read `references/reading.md` — it carries the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs. - -If the request creates, updates or deletes a file, read `references/writing.md` — it carries the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails. +| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` — the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs | +| Create, update, or delete a file | Write | `references/writing.md` — the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails | A request that reads and then writes runs both flows in that order: fetch the file first, then write with the SHA that call returned. diff --git a/plugins/gitea/.apm/skills/gitea-files/references/writing.md b/plugins/gitea/.apm/skills/gitea-files/references/writing.md index c8c96fd..b57eb2d 100644 --- a/plugins/gitea/.apm/skills/gitea-files/references/writing.md +++ b/plugins/gitea/.apm/skills/gitea-files/references/writing.md @@ -29,15 +29,17 @@ commit lands on it in one call, replacing a separate branch-creation step. ## Delete a file -Same SHA-first pattern, with no create-style fallback — `delete_file` without `sha` returns -HTTP 422. +Same SHA-first pattern, with no create-style fallback. `sha` is schema-**required** on +`delete_file`, unlike `create_or_update_file` where omitting it means *create* — so an omitted `sha` +is rejected client-side by input validation and the call never reaches Gitea. The HTTP 422 that is +actually reachable here is the stale-`sha` case. 1. `get_file_contents(owner, repo, ref: <branch>, path)` → read the top-level `sha`. 2. `delete_file(owner, repo, path, message, branch_name, sha: <that value>)`. ## Worked sequence — new file on a new branch, then a PR -``` +```text 1. create_or_update_file owner, repo path: "docs/example.md" @@ -58,9 +60,10 @@ when the write replaces an existing one. | Symptom | Cause | Action | |---|---|---| | HTTP 409 | `sha` omitted on a path that already exists | Fetch the current SHA, retry as an update | -| HTTP 422 | `sha` missing or stale | Re-fetch the SHA immediately before the write | +| HTTP 422 | Stale `sha` — the file changed between the read and the write | Re-fetch the SHA immediately before the write | | 403 or 422 with no SHA explanation | Branch protection requires signed commits | Stop and report | | HTTP 413 | Reverse-proxy body limit in front of Gitea | Report; retrying cannot fix it | +| Client-side input-validation error naming `sha` | `sha` omitted on `delete_file`, where it is schema-required | Fetch the current SHA and retry — nothing was sent to Gitea | | HTTP 404 | Wrong path, or a token without `write:repository` | Verify the path, then the token's scopes | **Signed commits.** These writes create commits server-side from a bare API token with no 2FA or diff --git a/plugins/gitea/.apm/skills/gitea-issues/README.md b/plugins/gitea/.apm/skills/gitea-issues/README.md index adf34d8..5c6549f 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/README.md +++ b/plugins/gitea/.apm/skills/gitea-issues/README.md @@ -10,9 +10,8 @@ its state, adding/editing comments, applying labels, and searching issues/PRs ac The create flow closes out four enrichments deferred from issue #6 comment #848: label inference and milestone assignment (both by composing `gitea-labels-milestones`), an assignee workaround for the blocked `get_me` scope, and the "Depends on #N" dependency-linking convention. It supersedes the -`issue`/`issue <N>`/`issue close <N>`/`issue comment <N>` dispatch in the old flat -`plugins/bin/skills/gitea/SKILL.md`, removed per -`docs/adr/0011-gitea-skill-deep-modules.md`. +`issue`/`issue <N>`/`issue close <N>`/`issue comment <N>` dispatch this plugin's old single flat +Gitea skill carried, retired when the plugin was split into per-domain deep modules. ## Before you start @@ -34,7 +33,7 @@ issue request: local git branch or commit work belongs to `gitea-branches` (Gite ## Usage -``` +```text /gitea-issues ``` diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md b/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md index 2977587..734351e 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/enrichments.md @@ -84,8 +84,8 @@ repo-scoped number space. Use the bare `#N` form for same-repo dependencies; use a dependency in a different repo. When creating an issue that depends on another, append a line like: -``` +```text Depends on #42 ``` -to the body before calling `issue_write method: "create"`. There is no separate field or follow-up -call — the rendering happens automatically once the body is saved. +to the body before calling `issue_write method: "create"`. No separate field or follow-up call is +involved — the rendering happens automatically once the body is saved. diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/issues.md b/plugins/gitea/.apm/skills/gitea-issues/references/issues.md index d1b0c92..fe4248b 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/issues.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/issues.md @@ -37,7 +37,7 @@ item is the `html_url` path segment (`/issues/` vs `/pulls/`), since `is_pull` i list items — see the Gotchas section of SKILL.md. **Call:** -``` +```text list_issues owner: <owner> repo: <repo> state: "open" type: "issues" ``` @@ -66,7 +66,7 @@ number is backed by a pull request — absent, not `false`, on true issues). to name strings, unlike the labels array on `get`). **Call:** -``` +```text issue_read method: "get" owner: <owner> repo: <repo> issue_number: <N> ``` @@ -95,7 +95,7 @@ gotcha in SKILL.md. - `remove_deadline` (boolean, optional) **Create:** -``` +```text issue_write method: "create" owner: <owner> repo: <repo> title: <title> body: <body> @@ -105,19 +105,19 @@ issue_write method: "create" ``` **Close:** -``` +```text issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed" ``` -There is no `method: "close"` — using one will error. +No `method: "close"` exists — using one errors. **Comment:** -``` +```text issue_write method: "add_comment" owner: <owner> repo: <repo> issue_number: <N> body: <text> ``` **Apply resolved label IDs directly** (bypassing `references/enrichments.md`'s inference step, e.g. when the caller already named exact labels): -``` +```text issue_write method: "add_labels" owner: <owner> repo: <repo> issue_number: <N> labels: [<IDs>] ``` To replace all labels atomically instead of adding: `method: "replace_labels"`. diff --git a/plugins/gitea/.apm/skills/gitea-issues/references/search.md b/plugins/gitea/.apm/skills/gitea-issues/references/search.md index 13b3714..672bad0 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/references/search.md +++ b/plugins/gitea/.apm/skills/gitea-issues/references/search.md @@ -22,12 +22,12 @@ time (see `references/sources.md`) — confirmed to match `api-reference.md`. - `per_page` (number, optional, default `30`) **Call:** -``` +```text search_issues query: <text> ``` **Narrowing the search:** -``` +```text search_issues query: <text> owner: <owner> state: "open" type: "pulls" labels: "bug,urgent" ``` diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md index d509c71..9088421 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/README.md @@ -14,7 +14,7 @@ That relationship is documented here rather than in the skill description, which ## Usage -``` +```text /gitea-labels-milestones ``` diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md index c6f505e..02d090e 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. -- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. +- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it on every repo label, so it is always *readable* per label; `label_write` documents it as "(org only)" because it is only *settable* through the org create methods. Where it is `true` Gitea enforces one-per-scope itself, and replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. ## Step 1 — Resolve owner, repo and org diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md index 5a3e96f..911b41d 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/label-inference.md @@ -12,15 +12,11 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy. `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user asks to label something without naming exact labels. -## Exclusivity is per label — read it, never infer it +## Branching on exclusivity -Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is -`true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/` -delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`, -`Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and -`Compat/Breaking` — is `exclusive: false` and is used stacked. - -So read each candidate label's own `exclusive` value from the resolution call and branch on it: +`references/labels.md` owns the exclusivity rule and the read-versus-write asymmetry behind it. Read +it there rather than assuming a scope's behaviour from its name. Inference needs only the branch: +carry each candidate label's own `exclusive` value forward from the resolution call and act on it. - **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not pre-remove the label already there, and do not compute a replacement set client-side. Inferring @@ -29,7 +25,7 @@ So read each candidate label's own `exclusive` value from the resolution call an same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security` at once. -There is no client-side exclusivity convention for this skill to enforce. +This skill enforces no client-side exclusivity convention of its own. ## Signal → label mapping @@ -42,6 +38,7 @@ There is no client-side exclusivity convention for this skill to enforce. | Improvement to existing behavior, "make X better", refactor with behavior change | `Kind/Enhancement` | | Docs-only change, README/comment/guide updates | `Kind/Documentation` | | Vulnerability, credential exposure, injection risk, auth bypass | `Kind/Security` | +| Test coverage, "add tests for X", a missing or flaky test, a test-only change | `Kind/Testing` | **`Priority/*`** (urgency): @@ -49,6 +46,7 @@ There is no client-side exclusivity convention for this skill to enforce. |---|---| | "blocking", "critical", "urgent", production-down | `Priority/Critical` | | "soon", "high priority", "should do this sprint" | `Priority/High` | +| "low priority", "nice to have", "whenever", explicitly deferred | `Priority/Low` | | No urgency signal present | `Priority/Medium` (default) | **`Status/*`** (workflow state): @@ -57,6 +55,10 @@ There is no client-side exclusivity convention for this skill to enforce. |---|---| | Explicit statement that the work is blocked on something else | `Status/Blocked` | +The label names in all three tables are the taxonomy this guide was written against; none of them is +guaranteed to exist on the target repo. Step 2 below resolves every inferred name against the live +label set, and a name that does not resolve is reported rather than substituted. + ## Procedure 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md index f6e9b40..8188386 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md @@ -46,30 +46,37 @@ runtime error from Gitea rather than a client-side validation error. ## List repo labels -``` +```text label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page: 50 ``` Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way to build a complete name → ID map — there is no lookup-by-name endpoint. -Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on -`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`, -while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea -enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately. -Read the field — never infer exclusivity from the `/` in a name. +Every returned repo label carries its own `exclusive` boolean, so exclusivity is always *readable* +per repo label. That does not contradict `label_write`'s schema, which annotates `exclusive` as +"(org only)": reading and setting are different questions, and only the setting half is org-scoped +(see "Create a label" below). Where the field is `true` Gitea enforces one-label-per-scope +server-side; where it is `false` labels in that scope stack legitimately. Read the field — never +infer exclusivity from the `/` in a name, and never carry another repo's map over. + +On the instance this skill was authored against (`Defame1297/holocron`) the split ran: every +`Priority/*`, `Reviewed/*` and `Status/*` label `exclusive: true`, every `Kind/*` label and +`Compat/Breaking` `exclusive: false`. That is one repo's configuration at one point in time, recorded +as a worked example of what the field looks like in practice — it is not a property of the taxonomy +and says nothing about the repo you are called against. ## Get one label -``` +```text label_read method: "get_repo_label" owner: <owner> repo: <repo> id: <id> ``` ## Resolve a name to an ID -There is no direct name lookup. List all repo labels (paginating if needed), scan for a -case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is -not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method +The tool surface carries no lookup-by-name method. List all repo labels (paginating if needed), +scan for a case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the +name is not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the owner is an organisation. Its failure modes are not interchangeable. `token does not have at least one of required scope(s), required=[read:organization]` means the org pool was never queried — report @@ -82,7 +89,7 @@ Resolution is the required first step before any label application on an issue o ## Create a label -``` +```text label_write method: "create_repo_label" owner: <owner> repo: <repo> name: "Kind/Bug" @@ -98,7 +105,7 @@ tool — it is set in the Gitea UI or against the REST API directly, and read ba ## Edit a label -``` +```text label_write method: "edit_repo_label" owner: <owner> repo: <repo> id: <id> color: "#ff0000" ``` @@ -106,7 +113,7 @@ Only pass the fields being changed — `id` plus any of `name`/`color`/`descript ## Delete a label -``` +```text label_write method: "delete_repo_label" owner: <owner> repo: <repo> id: <id> ``` diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/milestones.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/milestones.md index d87ee72..41dc508 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/milestones.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/milestones.md @@ -43,7 +43,7 @@ schema level — there's no scope variant to omit them for. ## List milestones -``` +```text milestone_read method: "list" owner: <owner> repo: <repo> state: "open" ``` @@ -51,7 +51,7 @@ Report each as: id, title, state, due date, open/closed issue counts. ## Get one milestone -``` +```text milestone_read method: "get" owner: <owner> repo: <repo> id: <id> ``` @@ -60,7 +60,7 @@ milestone_read method: "get" owner: <owner> repo: <repo> id: <id> Needed whenever the only handle available is a title — e.g. a `pull_request_read` response, which returns `milestone` as a bare title string rather than `{id, title}`. Call: -``` +```text milestone_read method: "list" owner: <owner> repo: <repo> name: <title> ``` @@ -69,7 +69,7 @@ title typo or case mismatch), fall back to listing without the filter and matchi ## Create a milestone -``` +```text milestone_write method: "create" owner: <owner> repo: <repo> title: "v1.0" @@ -82,7 +82,7 @@ this milestone via `issue_write`/`pull_request_write`. ## Update or close a milestone -``` +```text milestone_write method: "update" owner: <owner> repo: <repo> id: <id> state: "closed" ``` @@ -90,7 +90,7 @@ Only pass the fields being changed — `id` plus any of `title`/`description`/`d ## Delete a milestone -``` +```text milestone_write method: "delete" owner: <owner> repo: <repo> id: <id> ``` diff --git a/plugins/gitea/.apm/skills/gitea-prs/README.md b/plugins/gitea/.apm/skills/gitea-prs/README.md index 4d07ed3..5825923 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/README.md +++ b/plugins/gitea/.apm/skills/gitea-prs/README.md @@ -8,11 +8,17 @@ This skill handles the pull request lifecycle within the Gitea integration suite ## Usage -``` +```text /gitea-prs ``` -Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create or update a PR, merge one, or create/submit/dismiss a code review. The skill will determine owner/repo from context and resolve any label or milestone names via `gitea-labels-milestones` before writing them. +Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create or update a PR, merge one, or create/submit/dismiss a code review. The skill resolves `owner`/`repo` from the `origin` git remote (or takes them from an orchestrating caller) and resolves any label or milestone names via `gitea-labels-milestones` before writing them. + +## Before you start + +Requires a Gitea MCP server configured with a token holding `write:issue` + `write:repository`. +Requires a git remote named `origin` pointing at the Gitea instance, unless an orchestrating caller +(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. ## Files diff --git a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md index b14dd3f..236e009 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md @@ -5,7 +5,10 @@ description: > Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even when the user does not say "Gitea". Not issues -> `gitea-issues`. -compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. +compatibility: Requires Gitea MCP server configured with write:issue and write:repository token + scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when + invoked directly by a human; an orchestrating caller (e.g. gitea-workflow) may pass owner/repo + already resolved. metadata: category: integration @@ -16,7 +19,7 @@ metadata: - context7-gitea-tea-cli version: "0.1.2" -allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write +allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write --- ## Gotchas @@ -24,9 +27,19 @@ allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp_ - **Issues and PRs share one number space.** `#42` may be an issue rather than a PR. When unsure, call `pull_request_read method: "get"` and read a 404 as "that number is an issue" — hand it to `gitea-issues`. - **`pull_request_write method: "create"` discards most optional parameters in silence.** `milestone`, `assignee`, `assignees`, `reviewers` and `team_reviewers` are accepted, dropped, and left out of the response, so a drop is indistinguishable from never passing them. `labels` *does* apply on `"create"`, so labels landing is no evidence the milestone did. -## Dispatch +## Step 1 — Resolve owner and repo -Resolve `owner` and `repo` from context first, and confirm the number names a PR before writing to it. +Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in: + +```bash +git remote get-url origin +``` + +If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." + +## Step 2 — Dispatch + +Confirm the number names a PR, not an issue, before writing to it. | Task | Tool | Reference | |---|---|---| @@ -36,11 +49,9 @@ Resolve `owner` and `repo` from context first, and confirm the number names a PR | Merge a PR, or judge whether it can merge | `pull_request_write method: "merge"` | `references/merging.md` | | Read, create, submit, dismiss or delete a code review, or reply to and resolve a review comment thread | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | -Whatever `"create"` dropped takes a second call once the PR exists — `"update"` for milestone and assignees, `"add_reviewers"` for reviewers. - Read the reference for the row you land on before making the call. Each carries the parameter signatures, the per-method behaviour and the response-shape quirks the row cannot, and every write method has at least one parameter that behaves differently from its issue-side counterpart. -## Resolving labels and milestones +## Step 3 — Resolving labels and milestones `labels` and `milestone` take numeric IDs, never name or title strings. Before a `pull_request_write` call carrying either, resolve them through `gitea-labels-milestones`: `label_read method: "list_repo_labels"` for a label name, `milestone_read method: "list"` for a milestone title. diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md index 04a6e2c..ec604fd 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md @@ -68,7 +68,7 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st Merge-specific parameters (`merge_style`, `delete_branch`, `force_merge`, `merge_when_checks_succeed`, `head_commit_id`, `message` as merge commit message) are covered in `references/merging.md`. -**`"create"` silently drops most optional parameters.** `"create"` reads only `owner`, `repo`, `title`, `body`, `head`, `base`, `draft`, `labels`, and `deadline`. Every other optional parameter — including `assignee`, `assignees`, `milestone`, `reviewers`, `team_reviewers` and `remove_deadline` — is accepted without error and discarded. There is no error, no warning, and nothing in the response distinguishing a dropped parameter from one that was never passed: the response simply omits the key. Setting any of them requires a second call after the PR exists — `"update"` for `assignee`/`assignees`/`milestone`, `"add_reviewers"` for `reviewers`/`team_reviewers`. +**`"create"` silently drops most optional parameters.** `"create"` reads only `owner`, `repo`, `title`, `body`, `head`, `base`, `draft`, `labels`, and `deadline`. Every other optional parameter — including `assignee`, `assignees`, `milestone`, `reviewers`, `team_reviewers` and `remove_deadline` — is accepted without error and discarded. Nothing marks the drop: no error, no warning, and nothing in the response distinguishing a dropped parameter from one that was never passed — the response simply omits the key. Setting any of them requires a second call after the PR exists — `"update"` for `assignee`/`assignees`/`milestone`, `"add_reviewers"` for `reviewers`/`team_reviewers`. Two things make this easy to miss: diff --git a/plugins/gitea/.apm/skills/gitea-releases/README.md b/plugins/gitea/.apm/skills/gitea-releases/README.md index 4a23a7d..16ffa1d 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/README.md +++ b/plugins/gitea/.apm/skills/gitea-releases/README.md @@ -6,9 +6,15 @@ Manage Gitea releases and tags — list, create, and delete releases (with draft This skill handles release and tag operations for a Gitea repository. It creates releases from a tag/target commitish with title, notes, and draft/prerelease flags; lists and paginates releases and tags; retrieves the latest release; and deletes releases and tags as separate, independent destructive operations. It resolves the numeric release id required for deletion instead of assuming a tag name will work. +## Before you start + +Requires a Gitea MCP server configured with a token holding `write:repository`. Requires a git remote +named `origin` pointing at the Gitea instance, unless an orchestrating caller already resolved +`owner`/`repo` for you. + ## Usage -``` +```text /gitea-releases ``` @@ -19,6 +25,6 @@ Describe your release/tag task: list releases, get the latest release, create a | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/call-signatures.md` | Tool parameters and response shapes derived from gitea-mcp source (see `references/sources.md`); input params for 3 of the 9 tools additionally live-cross-checked | +| `references/call-signatures.md` | Tool parameters and response shapes derived from gitea-mcp source (see `references/sources.md`); input params for all nine tools additionally cross-checked live against gitea-mcp v1.7.0 | | `references/conventions.md` | Semver/draft/prerelease practitioner conventions and pagination behavior | | `references/sources.md` | Research sources backing the call signatures and conventions | diff --git a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md index c66d7cc..1fbaa24 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md @@ -6,22 +6,40 @@ description: > create, or delete either — even when the user does not say "release" or "Gitea". Not branches or commit history -> `gitea-branches`. +compatibility: Requires Gitea MCP server configured with a token with write:repository scope, which + gates every release and tag tool here. Requires git remote "origin" pointing to the Gitea instance + for owner/repo resolution, unless an orchestrating caller passes them already resolved. + metadata: - category: gitea + category: integration + version: "0.1.0" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli + +allowed-tools: Bash mcp__gitea__list_releases mcp__gitea__get_release mcp__gitea__get_latest_release mcp__gitea__create_release mcp__gitea__delete_release mcp__gitea__list_tags mcp__gitea__get_tag mcp__gitea__create_tag mcp__gitea__delete_tag --- ## Gotchas -- **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. -- **Set `is_draft`/`is_pre_release` explicitly on every `create_release` — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** A tag named `v2.0.0-beta.1` publishes as a full release, and becomes the repo's latest, unless `is_pre_release: true` is passed in the same call. The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. -- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. +- **`delete_release` takes the numeric `id`, never a `tag_name`; `delete_tag` takes the tag name, never an id.** Holding only a tag name, resolve the release id through `list_releases` or `get_release` first — a tag name passed to `delete_release` fails, and that failure is not evidence the release is already gone. +- **Deleting a release never deletes its tag**, and the reverse direction is *unconfirmed* — verify with `list_releases`/`get_release` after `delete_tag`. Removing both takes two independent destructive calls. +- **Set `is_draft`/`is_pre_release` explicitly on every `create_release`** — Gitea infers neither from a `-beta`/`-rc` tag name, so `v2.0.0-beta.1` publishes as a full release and becomes the repo's latest. `draft`/`prerelease` are output field names only; passing `draft` as an input key is silently ignored. +- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts pages. -## Dispatch table +## Step 1 — Resolve owner and repo + +`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already: + +```bash +git remote get-url origin +``` + +If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." + +## Step 2 — Dispatch | Action | Tool | Required params | Optional params | |---|---|---|---| @@ -37,13 +55,17 @@ metadata: `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. -Pass a caller-supplied `tag_name` through verbatim. Semver with a `v` prefix is a tooling convention, not a Gitea constraint — the API accepts any string — so never validate or rewrite it. +Pass a caller-supplied `tag_name` through verbatim — the API accepts any string, and semver with a `v` prefix is a tooling convention rather than a Gitea constraint. -## Workflow +## Step 3 — Procedure for the scenario in hand -- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. **There is no update or edit tool on this surface**: `create_release`, `get_release`, `get_latest_release`, `list_releases` and `delete_release` are the whole set. A release published with the wrong flag therefore has no non-destructive repair — the only fix is `delete_release` plus a fresh `create_release`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. -- [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. -- [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. -- [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. +These four are mutually exclusive — pick the one row the request lands on. + +| Scenario | Procedure | +|---|---| +| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release. | +| Delete a release | Resolve the numeric `id` per the first Gotcha, confirm intent, then call `delete_release`. The tag survives. | +| Delete a tag along with its release | Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. | +| List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. | If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, draft/prerelease semantics, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md b/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md index 49369d4..b81a6f9 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/call-signatures.md @@ -11,11 +11,12 @@ Signatures and response shapes are derived from gitea-mcp source (`operation/*.g see `references/sources.md`) rather than copied from upstream API docs, which can drift from the deployed gitea-mcp version — but this is a source-code extraction, not a live MCP tool call. -Input parameter schemas for 3 of the 9 tools here — `create_release`, `delete_tag`, and -`get_latest_release` — were additionally cross-checked live via `ToolSearch` against the deployed -`mcp__gitea__*` tools in session 2026-07-05, and confirmed to match exactly (required/optional -params and names). That check covered only input params for those 3 tools, not response shapes, -and not the other 6 tools — treat the rest of this document as source-derived, not live-verified. +Input parameter schemas for all 9 tools here were additionally cross-checked live via `ToolSearch` +against the deployed `mcp__gitea__*` tools and confirmed to match exactly — required and optional +params, names, and defaults. Last verified against gitea-mcp **v1.7.0**, as reported by +`get_gitea_mcp_server_version`. That check covers input params only: the response shapes below +remain source-derived, not live-verified, so re-verify them if a response reads differently than +documented here. `owner` and `repo` are required strings on every tool below and are omitted from the per-tool lists for brevity. @@ -44,7 +45,7 @@ for brevity. - Does not delete the underlying tag. **Release object shape** (returned by list/get/create/latest): -``` +```text id, tag_name, target, title, body, draft, prerelease, html_url, author, created_at, published_at ``` `author` is the creator's login. `body` holds the release notes. diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/sources.md b/plugins/gitea/.apm/skills/gitea-releases/references/sources.md index cffb8b9..c701c58 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/sources.md @@ -3,7 +3,7 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. +- **Description:** Official gitea-mcp repository; operation/*.go source files documenting the MCP tools, their parameters, and CLI flags. Originally extracted at v1.3.0; the input parameter schemas in `references/call-signatures.md` were re-verified live via `ToolSearch` against the deployed server, **last verified at v1.7.0** as reported by `get_gitea_mcp_server_version`. - **Research doc:** plugins/gitea/docs/research/docs/gitea/api-reference.md (Releases and Tags section); plugins/gitea/docs/research/docs/gitea/troubleshooting.md (`delete_release` numeric-id gotcha, `per_page` defaults) **Contributing files:** diff --git a/plugins/gitea/.apm/skills/gitea-workflow/README.md b/plugins/gitea/.apm/skills/gitea-workflow/README.md index 02ebd19..5b9eff8 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/README.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/README.md @@ -8,7 +8,7 @@ This skill is the conversational front door to the Gitea suite — it replaces t ## Usage -``` +```text /gitea-workflow ``` diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md b/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md index e556e75..702aa91 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/skill-index.md @@ -13,7 +13,7 @@ The request names a capability but not obviously which skill owns it. Find the o | `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | | `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | | `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | -| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | +| `gitea-branches` | Branch list/create/rename/delete, plus commit history (list commits, get a single commit by SHA). | | `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | | `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | diff --git a/plugins/gitea/skills/gitea-branches/README.md b/plugins/gitea/skills/gitea-branches/README.md index 3a99f72..1b7756e 100644 --- a/plugins/gitea/skills/gitea-branches/README.md +++ b/plugins/gitea/skills/gitea-branches/README.md @@ -4,7 +4,7 @@ Manage Gitea repository branches and inspect commit history via the Gitea MCP se ## What it does -This skill handles branch lifecycle operations (list, create, delete) and read-only commit +This skill handles branch lifecycle operations (list, create, rename, delete) and read-only commit history (list commits, get a single commit's full detail) against a Gitea repository. It resolves `owner`/`repo` from the git remote, dispatches to the right MCP tool, and applies safety and pagination conventions specific to Gitea's API (e.g. refusing to delete a protected branch without @@ -18,25 +18,26 @@ Branch references that only exist relative to a pull request — a PR's head or cross-repo fork PR heads in particular — belong to `gitea-prs`; `list_branches` cannot see a fork's head at all. -The skill triggers on phrasings like "list branches", "create a branch", "delete a branch", +The skill triggers on phrasings like "list branches", "create a branch", "rename a branch", "delete a branch", "what commits are on this branch", "show commit <sha>", and "what changed in that commit", even when the user does not say "Gitea", as long as the repo's remote is a Gitea instance. ## Before you start Requires a Gitea MCP server configured with a token that has `write:repository` scope. This is -confirmed for `list_branches`, `create_branch`, and `delete_branch` (Gitea gates reads behind write +confirmed for `list_branches`, `create_branch`, and `delete_branch` (and inferred for +`rename_branch`) (Gitea gates reads behind write scope for repo-scoped operations); `list_commits` and `get_commit` are inferred to need the same scope by analogy, not explicitly confirmed — see `references/commits.md`. Requires a git remote named `origin` pointing at the Gitea instance. ## Usage -``` +```text /gitea-branches ``` -Describe your task: list/create/delete a branch, or list/inspect commits. See `SKILL.md`'s +Describe your task: list/create/rename/delete a branch, or list/inspect commits. See `SKILL.md`'s dispatch table for the full set of recognized invocations. ## Files @@ -44,6 +45,6 @@ dispatch table for the full set of recognized invocations. | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents — dispatch table, gotchas | -| `references/branches.md` | Verified call signatures and mechanics for list/create/delete branch | +| `references/branches.md` | Verified call signatures and mechanics for list/create/rename/delete branch | | `references/commits.md` | Verified call signatures and mechanics for list/get commit | | `references/sources.md` | Research sources backing the branch/commit guidance | diff --git a/plugins/gitea/skills/gitea-branches/SKILL.md b/plugins/gitea/skills/gitea-branches/SKILL.md index fc112ff..96af538 100644 --- a/plugins/gitea/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/skills/gitea-branches/SKILL.md @@ -2,10 +2,10 @@ name: gitea-branches description: > - Use when listing, creating, or deleting branches in a Gitea repository, or - reading its commit history — even when the user does not say "Gitea". Not a - local working copy's branches -> `git-branches`. Not local history -> - `git-history`. Not a PR's head or base branch -> `gitea-prs`. + Use when listing, creating, renaming, or deleting branches in a Gitea repository, + or reading its commit history — even when the user does not say "Gitea". Not a + local checkout's branches -> `git-branches`. Not local history -> + `git-history`. Not a PR's head or base -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. @@ -17,7 +17,7 @@ metadata: - gitea-mcp-slim-go - context7-websites-gitea -allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__gitea__delete_branch mcp__gitea__list_commits mcp__gitea__get_commit +allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__gitea__rename_branch mcp__gitea__delete_branch mcp__gitea__list_commits mcp__gitea__get_commit --- ## Gotchas @@ -42,17 +42,18 @@ git remote get-url origin |---|---| | `/gitea-branches` or `/gitea-branches list` | List branches | | `/gitea-branches create <name> [from <base>]` | Create branch | +| `/gitea-branches rename <name> to <new-name>` | Rename branch | | `/gitea-branches delete <name>` | Delete branch | | `/gitea-branches commits [on <branch>] [touching <path>]` | List commit history | | `/gitea-branches commit <sha>` | Get full detail for one commit | -For branch operations (list/create/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. +For branch operations (list/create/rename/delete), read `references/branches.md` — it carries the call signatures, the `old_branch` source rule, and the protected-branch refusal in full. For commit operations (list/get), read `references/commits.md`. ## Step 3 — Report For reads: display branches as name + protected flag; display commits as SHA (short), message summary, author, date. -For writes (create/delete): confirm the action taken, the branch name, and (for create) the base it forked from. +For writes (create/rename/delete): confirm the action taken, the branch name, and (for create) the base it forked from or (for rename) the name it had before. For errors: surface the HTTP code and message, applying the 404 gotcha above before reporting "not found" to the user. diff --git a/plugins/gitea/skills/gitea-branches/references/branches.md b/plugins/gitea/skills/gitea-branches/references/branches.md index ff4e239..d15cb8a 100644 --- a/plugins/gitea/skills/gitea-branches/references/branches.md +++ b/plugins/gitea/skills/gitea-branches/references/branches.md @@ -7,10 +7,11 @@ source_keys: # Branch operations -Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch` -at authoring time, not copied from research docs — this is deliberate: research docs are generated -from source code at a point in time and can drift from the server actually deployed. Re-verify -against the live schema if these tools appear to behave differently than documented here. +Call signatures below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, +not copied from research docs — this is deliberate: research docs are generated from source code at +a point in time and can drift from the server actually deployed. **Last verified against gitea-mcp +v1.7.0**, as reported by `get_gitea_mcp_server_version`. Re-verify against the live schema if the +deployed version differs or these tools behave differently than documented here. ## `list_branches` @@ -21,7 +22,7 @@ against the live schema if these tools appear to behave differently than documen - `per_page` (number, optional, default: `30`) **Call:** -``` +```text list_branches owner: <owner> repo: <repo> ``` @@ -41,7 +42,7 @@ count is less than `per_page`. branch server-side (not necessarily your current local checkout) **Call:** -``` +```text create_branch owner: <owner> repo: <repo> branch: <new-name> old_branch: <source-branch> ``` @@ -53,6 +54,29 @@ top-level request with no working branch context), omit `old_branch` and let it A branch name collision returns `409 Conflict`. +## `rename_branch` + +**Parameters:** +- `owner` (string, required) +- `repo` (string, required) +- `branch` (string, required) — the branch's current name +- `new_name` (string, required) — the name to move it to + +**Call:** +```text +rename_branch owner: <owner> repo: <repo> branch: <current-name> new_name: <new-name> +``` + +A rename moves the ref server-side; it is not a delete-plus-create, and no commit history is +rewritten. What it does to things *pointing at* the old name — open pull requests using it as head or +base, a branch protection rule matching it, CI config, and tracking branches on every other clone — +is **not confirmed** by this skill's sources: the deployed tool describes itself only as "Rename an +existing branch in a repository". Treat a rename of a branch with open PRs or a protection rule as a +change needing verification afterward (`list_branches`, plus `gitea-prs` for the PR side), and +confirm with the user first, exactly as for `delete_branch` below. A collision with an existing +branch name is expected to return `409 Conflict` by analogy with `create_branch`, not separately +confirmed. + ## `delete_branch` **Parameters:** @@ -61,7 +85,7 @@ A branch name collision returns `409 Conflict`. - `branch` (string, required) **Call:** -``` +```text delete_branch owner: <owner> repo: <repo> branch: <name> ``` @@ -73,9 +97,12 @@ protected, every time, regardless of how the request is phrased. ## Token scope -All three — `list_branches`, `create_branch`, `delete_branch` — require `write:repository`. Gitea +`list_branches`, `create_branch` and `delete_branch` all require `write:repository`. Gitea gates reads behind write scope for repo-scoped operations, so `list_branches` needs the same scope as the write operations, not `write:issue` alone. An earlier version of this doc claimed `write:issue` alone was sufficient for `list_branches`, based on empirical testing under a token that held both `write:issue` and `write:repository` simultaneously — that test didn't isolate the variable, so it couldn't actually establish `write:issue` alone as sufficient. + +`rename_branch` is a write on the same repo-scoped surface and is inferred to need `write:repository` +too — inferred by analogy, not separately confirmed. diff --git a/plugins/gitea/skills/gitea-branches/references/commits.md b/plugins/gitea/skills/gitea-branches/references/commits.md index 12c1d68..552a74a 100644 --- a/plugins/gitea/skills/gitea-branches/references/commits.md +++ b/plugins/gitea/skills/gitea-branches/references/commits.md @@ -8,8 +8,9 @@ source_keys: # Commit operations Read-only commit history, scoped to a repo (optionally to one branch or one path). Call signatures -below were verified live against the deployed `gitea-mcp` server via `ToolSearch` at authoring time, -not copied from research docs, for the same drift-avoidance reason noted in `references/branches.md`. +below were verified live against the deployed `gitea-mcp` server via `ToolSearch`, not copied from +research docs, for the same drift-avoidance reason noted in `references/branches.md`. **Last verified +against gitea-mcp v1.7.0**, as reported by `get_gitea_mcp_server_version`. This domain has no prior skill precedent — it's new coverage added alongside branches because commit history is naturally scoped to a branch (a "what happened on this branch" question), not because it @@ -27,7 +28,7 @@ shares any tool family with branch create/delete. - `per_page` (number, optional, default: `30`, minimum: `1`) **Call:** -``` +```text list_commits owner: <owner> repo: <repo> sha: <branch-or-sha> path: <optional-path> ``` @@ -52,7 +53,7 @@ Paginate per the pagination Gotcha in SKILL.md if you need more than one page of - `sha` (string, required) **Call:** -``` +```text get_commit owner: <owner> repo: <repo> sha: <commit-sha> ``` diff --git a/plugins/gitea/skills/gitea-branches/references/sources.md b/plugins/gitea/skills/gitea-branches/references/sources.md index 0341ef5..76fb66e 100644 --- a/plugins/gitea/skills/gitea-branches/references/sources.md +++ b/plugins/gitea/skills/gitea-branches/references/sources.md @@ -2,8 +2,8 @@ **Note on call signatures:** per `docs/adr/0011-gitea-skill-deep-modules.md`, the tool parameter signatures in `references/branches.md` and `references/commits.md` were re-verified live via -`ToolSearch` against the deployed `gitea-mcp` server at authoring time — they are not copied -verbatim from `api-reference.md` below. This resolves issue #6 comment #849's root-cause finding +`ToolSearch` against the deployed `gitea-mcp` server — **last verified against v1.7.0**, as reported +by `get_gitea_mcp_server_version` — rather than copied verbatim from `api-reference.md` below. This resolves issue #6 comment #849's root-cause finding that a prior skill was authored from API docs that had drifted from the actual MCP tool schema. The research docs cited here informed gotchas, response shapes, and workflow context, not the parameter lists themselves. @@ -11,7 +11,7 @@ parameter lists themselves. ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. Informed the dispatch table and pagination / 404-may-mean-403 gotchas in SKILL.md, and the list/create/delete branch and list/get commit mechanics (including 409 conflict and default-branch fallback behavior) in references/branches.md and references/commits.md. +- **Description:** Official gitea-mcp repository; operation/*.go source files documenting the MCP tools, their parameters, and CLI flags. Extracted at v1.3.0; the parameter lists carried into this skill are re-verified live against the deployed server, last at v1.7.0. Informed the dispatch table and pagination / 404-may-mean-403 gotchas in SKILL.md, and the list/create/delete branch and list/get commit mechanics (including 409 conflict and default-branch fallback behavior) in references/branches.md and references/commits.md. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** SKILL.md, references/branches.md, references/commits.md - **Status:** `extracted` @@ -19,7 +19,7 @@ parameter lists themselves. ## gitea-mcp-slim-go - **URL:** https://gitea.com/gitea/gitea-mcp/raw/branch/main/operation/repo/slim.go -- **Description:** Slim response shape structs from gitea-mcp source; defines exactly which fields the MCP server returns for branches (name, protected, commit_sha) and commits (sha, html_url, created, message, author), and informed get_commit's always-populated guarantee vs. list_commits' conditional fields. +- **Description:** Slim response shape structs from gitea-mcp source, extracted at v1.3.0; defines exactly which fields the MCP server returns for branches (name, protected, commit_sha) and commits (sha, html_url, created, message, author), and informed get_commit's always-populated guarantee vs. list_commits' conditional fields. - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **Contributing files:** references/branches.md, references/commits.md - **Status:** `extracted` diff --git a/plugins/gitea/skills/gitea-files/README.md b/plugins/gitea/skills/gitea-files/README.md index 333e899..3e38b79 100644 --- a/plugins/gitea/skills/gitea-files/README.md +++ b/plugins/gitea/skills/gitea-files/README.md @@ -8,7 +8,7 @@ This skill handles file-domain operations within the Gitea integration suite: re ## Usage -``` +```text /gitea-files ``` diff --git a/plugins/gitea/skills/gitea-files/SKILL.md b/plugins/gitea/skills/gitea-files/SKILL.md index ace6379..555782e 100644 --- a/plugins/gitea/skills/gitea-files/SKILL.md +++ b/plugins/gitea/skills/gitea-files/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g - **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. - **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. -- **`content` is base64 both ways.** Encode before a write, decode after a read; `withLines: true` returns numbered lines. +- **`content` is base64 both ways.** Encode before a write, decode after a read. ## Inputs @@ -32,14 +32,12 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Dispatch +Read the reference for the row you land on before making the call. + | Condition | Flow | Reference | |---|---|---| -| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` | -| Create, update, or delete a file | Write | `references/writing.md` | - -If the request only inspects repository contents, read `references/reading.md` — it carries the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs. - -If the request creates, updates or deletes a file, read `references/writing.md` — it carries the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails. +| Read one file, list one directory level, or walk the repository tree | Read | `references/reading.md` — the three read tools, their pagination behaviour, and why neither a directory listing nor a tree entry supplies the SHA a write needs | +| Create, update, or delete a file | Write | `references/writing.md` — the SHA-first sequence every update and delete depends on, the worked multi-call sequence, and how to triage a write that fails | A request that reads and then writes runs both flows in that order: fetch the file first, then write with the SHA that call returned. diff --git a/plugins/gitea/skills/gitea-files/references/writing.md b/plugins/gitea/skills/gitea-files/references/writing.md index c8c96fd..b57eb2d 100644 --- a/plugins/gitea/skills/gitea-files/references/writing.md +++ b/plugins/gitea/skills/gitea-files/references/writing.md @@ -29,15 +29,17 @@ commit lands on it in one call, replacing a separate branch-creation step. ## Delete a file -Same SHA-first pattern, with no create-style fallback — `delete_file` without `sha` returns -HTTP 422. +Same SHA-first pattern, with no create-style fallback. `sha` is schema-**required** on +`delete_file`, unlike `create_or_update_file` where omitting it means *create* — so an omitted `sha` +is rejected client-side by input validation and the call never reaches Gitea. The HTTP 422 that is +actually reachable here is the stale-`sha` case. 1. `get_file_contents(owner, repo, ref: <branch>, path)` → read the top-level `sha`. 2. `delete_file(owner, repo, path, message, branch_name, sha: <that value>)`. ## Worked sequence — new file on a new branch, then a PR -``` +```text 1. create_or_update_file owner, repo path: "docs/example.md" @@ -58,9 +60,10 @@ when the write replaces an existing one. | Symptom | Cause | Action | |---|---|---| | HTTP 409 | `sha` omitted on a path that already exists | Fetch the current SHA, retry as an update | -| HTTP 422 | `sha` missing or stale | Re-fetch the SHA immediately before the write | +| HTTP 422 | Stale `sha` — the file changed between the read and the write | Re-fetch the SHA immediately before the write | | 403 or 422 with no SHA explanation | Branch protection requires signed commits | Stop and report | | HTTP 413 | Reverse-proxy body limit in front of Gitea | Report; retrying cannot fix it | +| Client-side input-validation error naming `sha` | `sha` omitted on `delete_file`, where it is schema-required | Fetch the current SHA and retry — nothing was sent to Gitea | | HTTP 404 | Wrong path, or a token without `write:repository` | Verify the path, then the token's scopes | **Signed commits.** These writes create commits server-side from a bare API token with no 2FA or diff --git a/plugins/gitea/skills/gitea-issues/README.md b/plugins/gitea/skills/gitea-issues/README.md index adf34d8..5c6549f 100644 --- a/plugins/gitea/skills/gitea-issues/README.md +++ b/plugins/gitea/skills/gitea-issues/README.md @@ -10,9 +10,8 @@ its state, adding/editing comments, applying labels, and searching issues/PRs ac The create flow closes out four enrichments deferred from issue #6 comment #848: label inference and milestone assignment (both by composing `gitea-labels-milestones`), an assignee workaround for the blocked `get_me` scope, and the "Depends on #N" dependency-linking convention. It supersedes the -`issue`/`issue <N>`/`issue close <N>`/`issue comment <N>` dispatch in the old flat -`plugins/bin/skills/gitea/SKILL.md`, removed per -`docs/adr/0011-gitea-skill-deep-modules.md`. +`issue`/`issue <N>`/`issue close <N>`/`issue comment <N>` dispatch this plugin's old single flat +Gitea skill carried, retired when the plugin was split into per-domain deep modules. ## Before you start @@ -34,7 +33,7 @@ issue request: local git branch or commit work belongs to `gitea-branches` (Gite ## Usage -``` +```text /gitea-issues ``` diff --git a/plugins/gitea/skills/gitea-issues/references/enrichments.md b/plugins/gitea/skills/gitea-issues/references/enrichments.md index 2977587..734351e 100644 --- a/plugins/gitea/skills/gitea-issues/references/enrichments.md +++ b/plugins/gitea/skills/gitea-issues/references/enrichments.md @@ -84,8 +84,8 @@ repo-scoped number space. Use the bare `#N` form for same-repo dependencies; use a dependency in a different repo. When creating an issue that depends on another, append a line like: -``` +```text Depends on #42 ``` -to the body before calling `issue_write method: "create"`. There is no separate field or follow-up -call — the rendering happens automatically once the body is saved. +to the body before calling `issue_write method: "create"`. No separate field or follow-up call is +involved — the rendering happens automatically once the body is saved. diff --git a/plugins/gitea/skills/gitea-issues/references/issues.md b/plugins/gitea/skills/gitea-issues/references/issues.md index d1b0c92..fe4248b 100644 --- a/plugins/gitea/skills/gitea-issues/references/issues.md +++ b/plugins/gitea/skills/gitea-issues/references/issues.md @@ -37,7 +37,7 @@ item is the `html_url` path segment (`/issues/` vs `/pulls/`), since `is_pull` i list items — see the Gotchas section of SKILL.md. **Call:** -``` +```text list_issues owner: <owner> repo: <repo> state: "open" type: "issues" ``` @@ -66,7 +66,7 @@ number is backed by a pull request — absent, not `false`, on true issues). to name strings, unlike the labels array on `get`). **Call:** -``` +```text issue_read method: "get" owner: <owner> repo: <repo> issue_number: <N> ``` @@ -95,7 +95,7 @@ gotcha in SKILL.md. - `remove_deadline` (boolean, optional) **Create:** -``` +```text issue_write method: "create" owner: <owner> repo: <repo> title: <title> body: <body> @@ -105,19 +105,19 @@ issue_write method: "create" ``` **Close:** -``` +```text issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed" ``` -There is no `method: "close"` — using one will error. +No `method: "close"` exists — using one errors. **Comment:** -``` +```text issue_write method: "add_comment" owner: <owner> repo: <repo> issue_number: <N> body: <text> ``` **Apply resolved label IDs directly** (bypassing `references/enrichments.md`'s inference step, e.g. when the caller already named exact labels): -``` +```text issue_write method: "add_labels" owner: <owner> repo: <repo> issue_number: <N> labels: [<IDs>] ``` To replace all labels atomically instead of adding: `method: "replace_labels"`. diff --git a/plugins/gitea/skills/gitea-issues/references/search.md b/plugins/gitea/skills/gitea-issues/references/search.md index 13b3714..672bad0 100644 --- a/plugins/gitea/skills/gitea-issues/references/search.md +++ b/plugins/gitea/skills/gitea-issues/references/search.md @@ -22,12 +22,12 @@ time (see `references/sources.md`) — confirmed to match `api-reference.md`. - `per_page` (number, optional, default `30`) **Call:** -``` +```text search_issues query: <text> ``` **Narrowing the search:** -``` +```text search_issues query: <text> owner: <owner> state: "open" type: "pulls" labels: "bug,urgent" ``` diff --git a/plugins/gitea/skills/gitea-labels-milestones/README.md b/plugins/gitea/skills/gitea-labels-milestones/README.md index d509c71..9088421 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/README.md +++ b/plugins/gitea/skills/gitea-labels-milestones/README.md @@ -14,7 +14,7 @@ That relationship is documented here rather than in the skill description, which ## Usage -``` +```text /gitea-labels-milestones ``` diff --git a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md index c6f505e..02d090e 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m - **Applying a label takes a numeric ID, but issue/PR responses slim labels down to name strings.** An issue's existing labels yield no IDs — resolve name → ID with `label_read`. - **`pull_request_read` returns `milestone` as a bare title string** where `issue_read` returns `{id, title}` — recover the milestone's ID by listing milestones and matching the title. -- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it per repo label, it is not org-only, and where it is `true` Gitea enforces one-per-scope itself. Replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. +- **Never assume a `Kind/*`/`Priority/*`/`Status/*` scope is exclusive — read each label's own `exclusive` field.** `list_repo_labels` returns it on every repo label, so it is always *readable* per label; `label_write` documents it as "(org only)" because it is only *settable* through the org create methods. Where it is `true` Gitea enforces one-per-scope itself, and replacing rather than stacking on a label whose `exclusive` is `false` destroys a valid label. ## Step 1 — Resolve owner, repo and org diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md index 5a3e96f..911b41d 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/label-inference.md @@ -12,15 +12,11 @@ description) to this repo's `Kind/*` / `Priority/*` / `Status/*` label taxonomy. `gitea-issues` and `gitea-prs` before creating or updating an issue/PR, and directly when the user asks to label something without naming exact labels. -## Exclusivity is per label — read it, never infer it +## Branching on exclusivity -Gitea's `exclusive` flag is a real per-label boolean returned by `list_repo_labels`, and where it is -`true` the server enforces one-label-per-scope itself. It is not an org-only setting, and the `/` -delimiter in a name says nothing about it. Verified on `Defame1297/holocron`: every `Priority/*`, -`Reviewed/*` and `Status/*` label is `exclusive: true`, while every `Kind/*` label — and -`Compat/Breaking` — is `exclusive: false` and is used stacked. - -So read each candidate label's own `exclusive` value from the resolution call and branch on it: +`references/labels.md` owns the exclusivity rule and the read-versus-write asymmetry behind it. Read +it there rather than assuming a scope's behaviour from its name. Inference needs only the branch: +carry each candidate label's own `exclusive` value forward from the resolution call and act on it. - **`exclusive: true`** — the server drops the sibling on write. Add the label and let it; do not pre-remove the label already there, and do not compute a replacement set client-side. Inferring @@ -29,7 +25,7 @@ So read each candidate label's own `exclusive` value from the resolution call an same scope destroys a valid one: an issue can legitimately carry `Kind/Bug` and `Kind/Security` at once. -There is no client-side exclusivity convention for this skill to enforce. +This skill enforces no client-side exclusivity convention of its own. ## Signal → label mapping @@ -42,6 +38,7 @@ There is no client-side exclusivity convention for this skill to enforce. | Improvement to existing behavior, "make X better", refactor with behavior change | `Kind/Enhancement` | | Docs-only change, README/comment/guide updates | `Kind/Documentation` | | Vulnerability, credential exposure, injection risk, auth bypass | `Kind/Security` | +| Test coverage, "add tests for X", a missing or flaky test, a test-only change | `Kind/Testing` | **`Priority/*`** (urgency): @@ -49,6 +46,7 @@ There is no client-side exclusivity convention for this skill to enforce. |---|---| | "blocking", "critical", "urgent", production-down | `Priority/Critical` | | "soon", "high priority", "should do this sprint" | `Priority/High` | +| "low priority", "nice to have", "whenever", explicitly deferred | `Priority/Low` | | No urgency signal present | `Priority/Medium` (default) | **`Status/*`** (workflow state): @@ -57,6 +55,10 @@ There is no client-side exclusivity convention for this skill to enforce. |---|---| | Explicit statement that the work is blocked on something else | `Status/Blocked` | +The label names in all three tables are the taxonomy this guide was written against; none of them is +guaranteed to exist on the target repo. Step 2 below resolves every inferred name against the live +label set, and a name that does not resolve is reported rather than substituted. + ## Procedure 1. Read the conversation context (issue/PR title, body, or the triggering discussion) for the diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md index f6e9b40..8188386 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md @@ -46,30 +46,37 @@ runtime error from Gitea rather than a client-side validation error. ## List repo labels -``` +```text label_read method: "list_repo_labels" owner: <owner> repo: <repo> per_page: 50 ``` Paginate (`page: 1, 2, ...`) until the returned count is less than `per_page`. This is the only way to build a complete name → ID map — there is no lookup-by-name endpoint. -Every returned repo label carries its own `exclusive` boolean; the field is not org-only. Verified on -`Defame1297/holocron`: all `Priority/*`, `Reviewed/*` and `Status/*` labels are `exclusive: true`, -while all `Kind/*` labels and `Compat/Breaking` are `exclusive: false`. Where it is `true` Gitea -enforces one-label-per-scope server-side; where it is `false` labels in that scope stack legitimately. -Read the field — never infer exclusivity from the `/` in a name. +Every returned repo label carries its own `exclusive` boolean, so exclusivity is always *readable* +per repo label. That does not contradict `label_write`'s schema, which annotates `exclusive` as +"(org only)": reading and setting are different questions, and only the setting half is org-scoped +(see "Create a label" below). Where the field is `true` Gitea enforces one-label-per-scope +server-side; where it is `false` labels in that scope stack legitimately. Read the field — never +infer exclusivity from the `/` in a name, and never carry another repo's map over. + +On the instance this skill was authored against (`Defame1297/holocron`) the split ran: every +`Priority/*`, `Reviewed/*` and `Status/*` label `exclusive: true`, every `Kind/*` label and +`Compat/Breaking` `exclusive: false`. That is one repo's configuration at one point in time, recorded +as a worked example of what the field looks like in practice — it is not a property of the taxonomy +and says nothing about the repo you are called against. ## Get one label -``` +```text label_read method: "get_repo_label" owner: <owner> repo: <repo> id: <id> ``` ## Resolve a name to an ID -There is no direct name lookup. List all repo labels (paginating if needed), scan for a -case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the name is -not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method +The tool surface carries no lookup-by-name method. List all repo labels (paginating if needed), +scan for a case-insensitive name match, and extract `id`. Both pools can apply to one issue: if the +name is not in `list_repo_labels`, also check `list_org_labels` before reporting it unresolved. That method takes `org`, not `owner`/`repo` — pass the repo's `owner` as `org`, which is what it means when the owner is an organisation. Its failure modes are not interchangeable. `token does not have at least one of required scope(s), required=[read:organization]` means the org pool was never queried — report @@ -82,7 +89,7 @@ Resolution is the required first step before any label application on an issue o ## Create a label -``` +```text label_write method: "create_repo_label" owner: <owner> repo: <repo> name: "Kind/Bug" @@ -98,7 +105,7 @@ tool — it is set in the Gitea UI or against the REST API directly, and read ba ## Edit a label -``` +```text label_write method: "edit_repo_label" owner: <owner> repo: <repo> id: <id> color: "#ff0000" ``` @@ -106,7 +113,7 @@ Only pass the fields being changed — `id` plus any of `name`/`color`/`descript ## Delete a label -``` +```text label_write method: "delete_repo_label" owner: <owner> repo: <repo> id: <id> ``` diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/milestones.md b/plugins/gitea/skills/gitea-labels-milestones/references/milestones.md index d87ee72..41dc508 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/milestones.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/milestones.md @@ -43,7 +43,7 @@ schema level — there's no scope variant to omit them for. ## List milestones -``` +```text milestone_read method: "list" owner: <owner> repo: <repo> state: "open" ``` @@ -51,7 +51,7 @@ Report each as: id, title, state, due date, open/closed issue counts. ## Get one milestone -``` +```text milestone_read method: "get" owner: <owner> repo: <repo> id: <id> ``` @@ -60,7 +60,7 @@ milestone_read method: "get" owner: <owner> repo: <repo> id: <id> Needed whenever the only handle available is a title — e.g. a `pull_request_read` response, which returns `milestone` as a bare title string rather than `{id, title}`. Call: -``` +```text milestone_read method: "list" owner: <owner> repo: <repo> name: <title> ``` @@ -69,7 +69,7 @@ title typo or case mismatch), fall back to listing without the filter and matchi ## Create a milestone -``` +```text milestone_write method: "create" owner: <owner> repo: <repo> title: "v1.0" @@ -82,7 +82,7 @@ this milestone via `issue_write`/`pull_request_write`. ## Update or close a milestone -``` +```text milestone_write method: "update" owner: <owner> repo: <repo> id: <id> state: "closed" ``` @@ -90,7 +90,7 @@ Only pass the fields being changed — `id` plus any of `title`/`description`/`d ## Delete a milestone -``` +```text milestone_write method: "delete" owner: <owner> repo: <repo> id: <id> ``` diff --git a/plugins/gitea/skills/gitea-prs/README.md b/plugins/gitea/skills/gitea-prs/README.md index 4d07ed3..5825923 100644 --- a/plugins/gitea/skills/gitea-prs/README.md +++ b/plugins/gitea/skills/gitea-prs/README.md @@ -8,11 +8,17 @@ This skill handles the pull request lifecycle within the Gitea integration suite ## Usage -``` +```text /gitea-prs ``` -Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create or update a PR, merge one, or create/submit/dismiss a code review. The skill will determine owner/repo from context and resolve any label or milestone names via `gitea-labels-milestones` before writing them. +Describe the PR or review task: list PRs, get a PR's status/diff/reviews, create or update a PR, merge one, or create/submit/dismiss a code review. The skill resolves `owner`/`repo` from the `origin` git remote (or takes them from an orchestrating caller) and resolves any label or milestone names via `gitea-labels-milestones` before writing them. + +## Before you start + +Requires a Gitea MCP server configured with a token holding `write:issue` + `write:repository`. +Requires a git remote named `origin` pointing at the Gitea instance, unless an orchestrating caller +(e.g. `gitea-workflow`) already resolved `owner`/`repo` for you. ## Files diff --git a/plugins/gitea/skills/gitea-prs/SKILL.md b/plugins/gitea/skills/gitea-prs/SKILL.md index b14dd3f..236e009 100644 --- a/plugins/gitea/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/skills/gitea-prs/SKILL.md @@ -5,7 +5,10 @@ description: > Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even when the user does not say "Gitea". Not issues -> `gitea-issues`. -compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. +compatibility: Requires Gitea MCP server configured with write:issue and write:repository token + scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when + invoked directly by a human; an orchestrating caller (e.g. gitea-workflow) may pass owner/repo + already resolved. metadata: category: integration @@ -16,7 +19,7 @@ metadata: - context7-gitea-tea-cli version: "0.1.2" -allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write +allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write --- ## Gotchas @@ -24,9 +27,19 @@ allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp_ - **Issues and PRs share one number space.** `#42` may be an issue rather than a PR. When unsure, call `pull_request_read method: "get"` and read a 404 as "that number is an issue" — hand it to `gitea-issues`. - **`pull_request_write method: "create"` discards most optional parameters in silence.** `milestone`, `assignee`, `assignees`, `reviewers` and `team_reviewers` are accepted, dropped, and left out of the response, so a drop is indistinguishable from never passing them. `labels` *does* apply on `"create"`, so labels landing is no evidence the milestone did. -## Dispatch +## Step 1 — Resolve owner and repo -Resolve `owner` and `repo` from context first, and confirm the number names a PR before writing to it. +Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in: + +```bash +git remote get-url origin +``` + +If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." + +## Step 2 — Dispatch + +Confirm the number names a PR, not an issue, before writing to it. | Task | Tool | Reference | |---|---|---| @@ -36,11 +49,9 @@ Resolve `owner` and `repo` from context first, and confirm the number names a PR | Merge a PR, or judge whether it can merge | `pull_request_write method: "merge"` | `references/merging.md` | | Read, create, submit, dismiss or delete a code review, or reply to and resolve a review comment thread | `pull_request_read`, `pull_request_review_write` | `references/reviews.md` | -Whatever `"create"` dropped takes a second call once the PR exists — `"update"` for milestone and assignees, `"add_reviewers"` for reviewers. - Read the reference for the row you land on before making the call. Each carries the parameter signatures, the per-method behaviour and the response-shape quirks the row cannot, and every write method has at least one parameter that behaves differently from its issue-side counterpart. -## Resolving labels and milestones +## Step 3 — Resolving labels and milestones `labels` and `milestone` take numeric IDs, never name or title strings. Before a `pull_request_write` call carrying either, resolve them through `gitea-labels-milestones`: `label_read method: "list_repo_labels"` for a label name, `milestone_read method: "list"` for a milestone title. diff --git a/plugins/gitea/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/skills/gitea-prs/references/pull-requests.md index 04a6e2c..ec604fd 100644 --- a/plugins/gitea/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/skills/gitea-prs/references/pull-requests.md @@ -68,7 +68,7 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st Merge-specific parameters (`merge_style`, `delete_branch`, `force_merge`, `merge_when_checks_succeed`, `head_commit_id`, `message` as merge commit message) are covered in `references/merging.md`. -**`"create"` silently drops most optional parameters.** `"create"` reads only `owner`, `repo`, `title`, `body`, `head`, `base`, `draft`, `labels`, and `deadline`. Every other optional parameter — including `assignee`, `assignees`, `milestone`, `reviewers`, `team_reviewers` and `remove_deadline` — is accepted without error and discarded. There is no error, no warning, and nothing in the response distinguishing a dropped parameter from one that was never passed: the response simply omits the key. Setting any of them requires a second call after the PR exists — `"update"` for `assignee`/`assignees`/`milestone`, `"add_reviewers"` for `reviewers`/`team_reviewers`. +**`"create"` silently drops most optional parameters.** `"create"` reads only `owner`, `repo`, `title`, `body`, `head`, `base`, `draft`, `labels`, and `deadline`. Every other optional parameter — including `assignee`, `assignees`, `milestone`, `reviewers`, `team_reviewers` and `remove_deadline` — is accepted without error and discarded. Nothing marks the drop: no error, no warning, and nothing in the response distinguishing a dropped parameter from one that was never passed — the response simply omits the key. Setting any of them requires a second call after the PR exists — `"update"` for `assignee`/`assignees`/`milestone`, `"add_reviewers"` for `reviewers`/`team_reviewers`. Two things make this easy to miss: diff --git a/plugins/gitea/skills/gitea-releases/README.md b/plugins/gitea/skills/gitea-releases/README.md index 4a23a7d..16ffa1d 100644 --- a/plugins/gitea/skills/gitea-releases/README.md +++ b/plugins/gitea/skills/gitea-releases/README.md @@ -6,9 +6,15 @@ Manage Gitea releases and tags — list, create, and delete releases (with draft This skill handles release and tag operations for a Gitea repository. It creates releases from a tag/target commitish with title, notes, and draft/prerelease flags; lists and paginates releases and tags; retrieves the latest release; and deletes releases and tags as separate, independent destructive operations. It resolves the numeric release id required for deletion instead of assuming a tag name will work. +## Before you start + +Requires a Gitea MCP server configured with a token holding `write:repository`. Requires a git remote +named `origin` pointing at the Gitea instance, unless an orchestrating caller already resolved +`owner`/`repo` for you. + ## Usage -``` +```text /gitea-releases ``` @@ -19,6 +25,6 @@ Describe your release/tag task: list releases, get the latest release, create a | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/call-signatures.md` | Tool parameters and response shapes derived from gitea-mcp source (see `references/sources.md`); input params for 3 of the 9 tools additionally live-cross-checked | +| `references/call-signatures.md` | Tool parameters and response shapes derived from gitea-mcp source (see `references/sources.md`); input params for all nine tools additionally cross-checked live against gitea-mcp v1.7.0 | | `references/conventions.md` | Semver/draft/prerelease practitioner conventions and pagination behavior | | `references/sources.md` | Research sources backing the call signatures and conventions | diff --git a/plugins/gitea/skills/gitea-releases/SKILL.md b/plugins/gitea/skills/gitea-releases/SKILL.md index c66d7cc..1fbaa24 100644 --- a/plugins/gitea/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/skills/gitea-releases/SKILL.md @@ -6,22 +6,40 @@ description: > create, or delete either — even when the user does not say "release" or "Gitea". Not branches or commit history -> `gitea-branches`. +compatibility: Requires Gitea MCP server configured with a token with write:repository scope, which + gates every release and tag tool here. Requires git remote "origin" pointing to the Gitea instance + for owner/repo resolution, unless an orchestrating caller passes them already resolved. + metadata: - category: gitea + category: integration + version: "0.1.0" source_keys: - gitea-mcp-repo - gitea-mcp-slim-go - context7-websites-gitea - context7-gitea-tea-cli + +allowed-tools: Bash mcp__gitea__list_releases mcp__gitea__get_release mcp__gitea__get_latest_release mcp__gitea__create_release mcp__gitea__delete_release mcp__gitea__list_tags mcp__gitea__get_tag mcp__gitea__create_tag mcp__gitea__delete_tag --- ## Gotchas -- **Deleting a release never deletes its tag.** A release is a metadata wrapper around a tag, so removing both takes two independent destructive calls. The reverse — whether deleting a tag deletes its release — is *unconfirmed*; verify with `list_releases`/`get_release` after `delete_tag` rather than assume it survives. -- **Set `is_draft`/`is_pre_release` explicitly on every `create_release` — Gitea never infers a prerelease from a `-beta`/`-rc` tag name.** A tag named `v2.0.0-beta.1` publishes as a full release, and becomes the repo's latest, unless `is_pre_release: true` is passed in the same call. The response object names them `draft`/`prerelease`; passing `draft` as an input key is silently ignored, not rejected. -- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts the pages a full sweep needs. +- **`delete_release` takes the numeric `id`, never a `tag_name`; `delete_tag` takes the tag name, never an id.** Holding only a tag name, resolve the release id through `list_releases` or `get_release` first — a tag name passed to `delete_release` fails, and that failure is not evidence the release is already gone. +- **Deleting a release never deletes its tag**, and the reverse direction is *unconfirmed* — verify with `list_releases`/`get_release` after `delete_tag`. Removing both takes two independent destructive calls. +- **Set `is_draft`/`is_pre_release` explicitly on every `create_release`** — Gitea infers neither from a `-beta`/`-rc` tag name, so `v2.0.0-beta.1` publishes as a full release and becomes the repo's latest. `draft`/`prerelease` are output field names only; passing `draft` as an input key is silently ignored. +- **`list_releases`/`list_tags` default `per_page` to 20**, where most other gitea-mcp list tools default to 30 — a caller assuming 30 under-counts pages. -## Dispatch table +## Step 1 — Resolve owner and repo + +`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already: + +```bash +git remote get-url origin +``` + +If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL." + +## Step 2 — Dispatch | Action | Tool | Required params | Optional params | |---|---|---|---| @@ -37,13 +55,17 @@ metadata: `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. -Pass a caller-supplied `tag_name` through verbatim. Semver with a `v` prefix is a tooling convention, not a Gitea constraint — the API accepts any string — so never validate or rewrite it. +Pass a caller-supplied `tag_name` through verbatim — the API accepts any string, and semver with a `v` prefix is a tooling convention rather than a Gitea constraint. -## Workflow +## Step 3 — Procedure for the scenario in hand -- [ ] **Creating a release:** Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. **There is no update or edit tool on this surface**: `create_release`, `get_release`, `get_latest_release`, `list_releases` and `delete_release` are the whole set. A release published with the wrong flag therefore has no non-destructive repair — the only fix is `delete_release` plus a fresh `create_release`. Gitea is assumed to create the tag from `target` when `tag_name` does not yet exist — plausible from the API shape, not confirmed in the research docs — so a separate `create_tag` is only needed to tag a commit without wrapping it in a release. Verify with `get_tag` afterward if the caller depends on it. -- [ ] **Deleting a release:** `delete_release` takes the numeric `id` and never a `tag_name`; `delete_tag` is the mirror opposite and never takes an id. With only a tag name in hand, resolve the id through `list_releases` (paginating if needed) or `get_release` first. -- [ ] **Deleting a tag along with its release:** Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. -- [ ] **Listing every page:** Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. +These four are mutually exclusive — pick the one row the request lands on. + +| Scenario | Procedure | +|---|---| +| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release. | +| Delete a release | Resolve the numeric `id` per the first Gotcha, confirm intent, then call `delete_release`. The tag survives. | +| Delete a tag along with its release | Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. | +| List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. | If exact input params or response field shapes are needed, read `references/call-signatures.md`. If the caller raises semver tag naming, draft/prerelease semantics, release-notes sourcing, or how a release relates to its tag, read `references/conventions.md`. diff --git a/plugins/gitea/skills/gitea-releases/references/call-signatures.md b/plugins/gitea/skills/gitea-releases/references/call-signatures.md index 49369d4..b81a6f9 100644 --- a/plugins/gitea/skills/gitea-releases/references/call-signatures.md +++ b/plugins/gitea/skills/gitea-releases/references/call-signatures.md @@ -11,11 +11,12 @@ Signatures and response shapes are derived from gitea-mcp source (`operation/*.g see `references/sources.md`) rather than copied from upstream API docs, which can drift from the deployed gitea-mcp version — but this is a source-code extraction, not a live MCP tool call. -Input parameter schemas for 3 of the 9 tools here — `create_release`, `delete_tag`, and -`get_latest_release` — were additionally cross-checked live via `ToolSearch` against the deployed -`mcp__gitea__*` tools in session 2026-07-05, and confirmed to match exactly (required/optional -params and names). That check covered only input params for those 3 tools, not response shapes, -and not the other 6 tools — treat the rest of this document as source-derived, not live-verified. +Input parameter schemas for all 9 tools here were additionally cross-checked live via `ToolSearch` +against the deployed `mcp__gitea__*` tools and confirmed to match exactly — required and optional +params, names, and defaults. Last verified against gitea-mcp **v1.7.0**, as reported by +`get_gitea_mcp_server_version`. That check covers input params only: the response shapes below +remain source-derived, not live-verified, so re-verify them if a response reads differently than +documented here. `owner` and `repo` are required strings on every tool below and are omitted from the per-tool lists for brevity. @@ -44,7 +45,7 @@ for brevity. - Does not delete the underlying tag. **Release object shape** (returned by list/get/create/latest): -``` +```text id, tag_name, target, title, body, draft, prerelease, html_url, author, created_at, published_at ``` `author` is the creator's login. `body` holds the release notes. diff --git a/plugins/gitea/skills/gitea-releases/references/sources.md b/plugins/gitea/skills/gitea-releases/references/sources.md index cffb8b9..c701c58 100644 --- a/plugins/gitea/skills/gitea-releases/references/sources.md +++ b/plugins/gitea/skills/gitea-releases/references/sources.md @@ -3,7 +3,7 @@ ## gitea-mcp-repo - **URL:** https://gitea.com/gitea/gitea-mcp -- **Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. +- **Description:** Official gitea-mcp repository; operation/*.go source files documenting the MCP tools, their parameters, and CLI flags. Originally extracted at v1.3.0; the input parameter schemas in `references/call-signatures.md` were re-verified live via `ToolSearch` against the deployed server, **last verified at v1.7.0** as reported by `get_gitea_mcp_server_version`. - **Research doc:** plugins/gitea/docs/research/docs/gitea/api-reference.md (Releases and Tags section); plugins/gitea/docs/research/docs/gitea/troubleshooting.md (`delete_release` numeric-id gotcha, `per_page` defaults) **Contributing files:** diff --git a/plugins/gitea/skills/gitea-workflow/README.md b/plugins/gitea/skills/gitea-workflow/README.md index 02ebd19..5b9eff8 100644 --- a/plugins/gitea/skills/gitea-workflow/README.md +++ b/plugins/gitea/skills/gitea-workflow/README.md @@ -8,7 +8,7 @@ This skill is the conversational front door to the Gitea suite — it replaces t ## Usage -``` +```text /gitea-workflow ``` diff --git a/plugins/gitea/skills/gitea-workflow/references/skill-index.md b/plugins/gitea/skills/gitea-workflow/references/skill-index.md index e556e75..702aa91 100644 --- a/plugins/gitea/skills/gitea-workflow/references/skill-index.md +++ b/plugins/gitea/skills/gitea-workflow/references/skill-index.md @@ -13,7 +13,7 @@ The request names a capability but not obviously which skill owns it. Find the o | `gitea-issues` | List/read/create/update issues, comments, search across issues and PRs. Composes `gitea-labels-milestones` for label/milestone resolution. | | `gitea-labels-milestones` | Label and milestone CRUD, label inference from conversation context, resolving names/titles to the numeric IDs writes require. Cross-cutting — used by both `gitea-issues` and `gitea-prs`. | | `gitea-prs` | List/read/create/update/merge PRs, code reviews. Composes `gitea-labels-milestones` the same way `gitea-issues` does. | -| `gitea-branches` | Branch list/create/delete, plus commit history (list commits, get a single commit by SHA). | +| `gitea-branches` | Branch list/create/rename/delete, plus commit history (list commits, get a single commit by SHA). | | `gitea-files` | Read/write/delete individual files, list a directory, walk the full repo tree. | | `gitea-releases` | Release and tag CRUD — draft/prerelease flags, release notes, semver tags. | -- 2.43.0 From 14af50bc071061e70e611110e13ebddf6b08754e Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:39 +0000 Subject: [PATCH 63/89] fix(git): scope git-workflow's trigger to ambiguity rather than to its domains git-workflow's description enumerated the six domains it exists to route away from, so it competed for selection with the very skills it should be handing off to. It now triggers on the case it actually serves: an interactive request whose domain is not yet clear. Also clears frontmatter drift across the plugin and removes duplicated guidance in pc-run that had diverged from its reference. --- plugins/git/.apm/skills/git-commits/SKILL.md | 1 - plugins/git/.apm/skills/git-remotes/SKILL.md | 5 ++--- .../.apm/skills/git-remotes/references/pull.md | 4 ++++ plugins/git/.apm/skills/git-submodules/SKILL.md | 2 +- .../skills/git-submodules/references/README.md | 5 ++--- plugins/git/.apm/skills/git-workflow/SKILL.md | 6 +++--- plugins/git/.apm/skills/git-worktrees/SKILL.md | 9 ++++----- .../git-worktrees/references/worktrees.md | 17 +++++++++++++---- plugins/git/.apm/skills/pc-run/SKILL.md | 4 +--- .../git/.apm/skills/pc-run/references/clean.md | 10 +++------- plugins/git/skills/git-commits/SKILL.md | 1 - plugins/git/skills/git-remotes/SKILL.md | 5 ++--- .../git/skills/git-remotes/references/pull.md | 4 ++++ plugins/git/skills/git-submodules/SKILL.md | 2 +- .../skills/git-submodules/references/README.md | 5 ++--- plugins/git/skills/git-workflow/SKILL.md | 6 +++--- plugins/git/skills/git-worktrees/SKILL.md | 9 ++++----- .../git-worktrees/references/worktrees.md | 17 +++++++++++++---- plugins/git/skills/pc-run/SKILL.md | 4 +--- plugins/git/skills/pc-run/references/clean.md | 10 +++------- 20 files changed, 66 insertions(+), 60 deletions(-) diff --git a/plugins/git/.apm/skills/git-commits/SKILL.md b/plugins/git/.apm/skills/git-commits/SKILL.md index 78b399f..4cc71c2 100644 --- a/plugins/git/.apm/skills/git-commits/SKILL.md +++ b/plugins/git/.apm/skills/git-commits/SKILL.md @@ -8,7 +8,6 @@ description: > Not branch lifecycle -> `git-branches`. metadata: - version: "0.1.3" category: git source_keys: - conventional-commits-spec diff --git a/plugins/git/.apm/skills/git-remotes/SKILL.md b/plugins/git/.apm/skills/git-remotes/SKILL.md index 5cc0eca..3cb6a01 100644 --- a/plugins/git/.apm/skills/git-remotes/SKILL.md +++ b/plugins/git/.apm/skills/git-remotes/SKILL.md @@ -9,21 +9,20 @@ description: > Not submodule pointers -> `git-submodules`. metadata: - category: git-workflow + category: git source_keys: - git-scm-remote-docs - git-scm-fetch-docs - git-scm-push-docs - git-scm-pull-docs - context7-git-htmldocs - --- ## Gotchas - **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) running `git fetch` silently defeat the protection. Combine it with `--force-if-includes`, or pin the explicit `--force-with-lease=<ref>:<sha>` form. - **Prune does not touch tags by default** — `git fetch --prune` leaves orphaned tags behind. Use `--prune --prune-tags`, or set `fetch.pruneTags true`. -- **Pull defaults shift between Git versions** — older ones default to merge, newer to `--ff-only`. Set `pull.ff only` explicitly rather than trusting the installed default. +- **Set `pull.ff only` explicitly** — do not trust the installed default. ## Step 1 — Clear the force-push gate diff --git a/plugins/git/.apm/skills/git-remotes/references/pull.md b/plugins/git/.apm/skills/git-remotes/references/pull.md index 3a8a25a..52be576 100644 --- a/plugins/git/.apm/skills/git-remotes/references/pull.md +++ b/plugins/git/.apm/skills/git-remotes/references/pull.md @@ -23,6 +23,10 @@ A pull that diverges with no strategy configured fails, and that failure is the ## Config precedence +The installed default varies by Git version — older versions merge on divergence, newer ones +default to `--ff-only` — so an unset `pull.ff` means the same pull behaves differently on different +machines. Set it explicitly. + Highest wins: 1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) diff --git a/plugins/git/.apm/skills/git-submodules/SKILL.md b/plugins/git/.apm/skills/git-submodules/SKILL.md index 60b86b4..e274475 100644 --- a/plugins/git/.apm/skills/git-submodules/SKILL.md +++ b/plugins/git/.apm/skills/git-submodules/SKILL.md @@ -51,7 +51,7 @@ executing. ## Output format -``` +```yaml operation: <clone|add|init|update|status|sync|set-url|set-branch|absorbgitdirs|deinit|remove> status: <success|error|partial> message: <one line; include git's own output on error> diff --git a/plugins/git/.apm/skills/git-submodules/references/README.md b/plugins/git/.apm/skills/git-submodules/references/README.md index 244de95..19b9784 100644 --- a/plugins/git/.apm/skills/git-submodules/references/README.md +++ b/plugins/git/.apm/skills/git-submodules/references/README.md @@ -1,7 +1,6 @@ --- -metadata: - source_keys: - - git-scm-submodule-docs +source_keys: + - git-scm-submodule-docs --- # References diff --git a/plugins/git/.apm/skills/git-workflow/SKILL.md b/plugins/git/.apm/skills/git-workflow/SKILL.md index 5dd21a8..e7e5034 100644 --- a/plugins/git/.apm/skills/git-workflow/SKILL.md +++ b/plugins/git/.apm/skills/git-workflow/SKILL.md @@ -2,9 +2,9 @@ name: git-workflow description: > - Use when a human wants to work through local git interactively — commits, branches, history, - submodules, worktrees, or remotes. Not an agent caller needing deterministic execution -> - `git-orchestrate`. Not server-side Gitea work -> `gitea-workflow`. + Use when a human's local git request is general or ambiguous — it routes to the owning + domain skill. Not an unambiguous commit -> `git-commits`. Not an unambiguous branch -> + `git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. metadata: category: git diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index f69223c..f89dcfe 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -28,7 +28,7 @@ metadata: | Create on a new branch | `git worktree add -b <branch> <path>` | | Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | | Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | -| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** when `<branch>` has no local copy (gate below) | +| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` | | Throwaway experiment, no branch | `git worktree add -d <path>` — detached HEAD | | **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above | | List | `git worktree list -v`, or `--porcelain -z` to parse | @@ -40,15 +40,14 @@ metadata: If the operation needs anything the table does not carry — the full `add` flag table, orphan branches, sparse-checkout, locking for removable media, remote -disambiguation across several remotes, worktree config keys, or the worked -emergency-fix and PR-review patterns — read `references/worktrees.md`. +disambiguation across several remotes, how to name a worktree unambiguously, +worktree config keys, or the worked emergency-fix and PR-review patterns — read +`references/worktrees.md`. Gates: - **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. - **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. -- **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. -- **`add` — the bare-name tracking shortcut needs exactly one remote.** `git worktree add <path> <branch>` sets up tracking only when `<branch>` is absent locally, no `-b`/`-B`/`-d` is given, and exactly one remote carries the name. With several, it fires only if `checkout.defaultRemote` names one. When the remote is ambiguous or unknown, use `--track -b`. - **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. ## Step 2 — Report diff --git a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md index a5cedef..64172f1 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md @@ -4,6 +4,8 @@ source_keys: - git-scm-worktree-docs --- +# Git worktrees + ## Shared vs. per-worktree state All worktrees share one object store, one config, and most refs under `refs/`. Each worktree keeps @@ -12,6 +14,12 @@ its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `re worktree** exists per repo — the one `git init` or `git clone` produced — and it cannot be removed or moved. Every other worktree is a **linked worktree** created by `git worktree add`. +## Identifying a worktree + +`lock`, `move`, `remove` and `repair` accept a full path, a unique basename, or a unique partial +path. An ambiguous name errors rather than picking one; `git worktree list` shows the identifiers +that are usable. + ## `add` forms ```bash @@ -85,10 +93,11 @@ git worktree unlock <path> # when reconnected git worktree add --track -b <branch> <path> <remote>/<branch> # explicit: no guessing at all git worktree add <path> <branch> # shortcut: needs one clear remote ``` -The shortcut fires only when `<branch>` is not found locally, none of `-b`/`-B`/`--detach` were -given, and a tracking branch of that name exists in exactly one remote. When several remotes carry -the name, `checkout.defaultRemote` picks one for disambiguation purposes; with no such setting the -shortcut has no single remote to resolve against and does not apply. +**The bare-name shortcut needs exactly one remote.** It fires only when `<branch>` is not found +locally, none of `-b`/`-B`/`--detach` were given, and a tracking branch of that name exists in +exactly one remote. When several remotes carry the name, `checkout.defaultRemote` picks one for +disambiguation purposes; with no such setting the shortcut has no single remote to resolve against +and does not apply. When the remote is ambiguous or unknown, use the explicit `--track -b` form. `--guess-remote` covers the *other* spelling — `git worktree add <path>` with no `<commit-ish>` at all. It bases the new branch on the remote-tracking branch matching `$(basename <path>)` when diff --git a/plugins/git/.apm/skills/pc-run/SKILL.md b/plugins/git/.apm/skills/pc-run/SKILL.md index 719b1ab..bc29166 100644 --- a/plugins/git/.apm/skills/pc-run/SKILL.md +++ b/plugins/git/.apm/skills/pc-run/SKILL.md @@ -20,7 +20,7 @@ allowed-tools: Bash Read - The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. - Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. -- `- files were modified by this hook` is not a bug. A fixer hook (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json --autofix`) rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. The fix is to re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — that flag overwrites hook files in `.git/hooks/` and has nothing to do with re-staging. +- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging. ## Gate — `pre-commit clean` @@ -47,8 +47,6 @@ Determine intent from the user's request, then execute the matching operation. W | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | | "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | -If the intent is ambiguous, default to `pre-commit run --all-files`. - ## Run Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. diff --git a/plugins/git/.apm/skills/pc-run/references/clean.md b/plugins/git/.apm/skills/pc-run/references/clean.md index 0ab452b..4fa6c44 100644 --- a/plugins/git/.apm/skills/pc-run/references/clean.md +++ b/plugins/git/.apm/skills/pc-run/references/clean.md @@ -6,16 +6,12 @@ source_keys: # Wiping the pre-commit cache -Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. Self-contained. +Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. The confirmation gate on `pre-commit clean` stays in `SKILL.md`, because it must fire on every path that reaches this command, not only this one. -## Gate — confirm first +## Gate -`pre-commit clean` wipes the whole cache at `~/.cache/pre-commit`, forcing every hook environment to be re-downloaded on the next run. Require explicit confirmation before executing it: - -> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" +`pre-commit clean` runs only after the confirmation gate in `SKILL.md` clears — that gate, its exact wording, and the `pre-commit gc` alternative live there and are not restated here. ```bash pre-commit clean ``` - -Prefer `pre-commit gc` when the goal is only to reclaim disk — it drops unused environments and leaves the ones in use intact, so it needs no confirmation. diff --git a/plugins/git/skills/git-commits/SKILL.md b/plugins/git/skills/git-commits/SKILL.md index 78b399f..4cc71c2 100644 --- a/plugins/git/skills/git-commits/SKILL.md +++ b/plugins/git/skills/git-commits/SKILL.md @@ -8,7 +8,6 @@ description: > Not branch lifecycle -> `git-branches`. metadata: - version: "0.1.3" category: git source_keys: - conventional-commits-spec diff --git a/plugins/git/skills/git-remotes/SKILL.md b/plugins/git/skills/git-remotes/SKILL.md index 5cc0eca..3cb6a01 100644 --- a/plugins/git/skills/git-remotes/SKILL.md +++ b/plugins/git/skills/git-remotes/SKILL.md @@ -9,21 +9,20 @@ description: > Not submodule pointers -> `git-submodules`. metadata: - category: git-workflow + category: git source_keys: - git-scm-remote-docs - git-scm-fetch-docs - git-scm-push-docs - git-scm-pull-docs - context7-git-htmldocs - --- ## Gotchas - **`--force-with-lease` alone is not safe** — background processes (IDE plugins, cron jobs) running `git fetch` silently defeat the protection. Combine it with `--force-if-includes`, or pin the explicit `--force-with-lease=<ref>:<sha>` form. - **Prune does not touch tags by default** — `git fetch --prune` leaves orphaned tags behind. Use `--prune --prune-tags`, or set `fetch.pruneTags true`. -- **Pull defaults shift between Git versions** — older ones default to merge, newer to `--ff-only`. Set `pull.ff only` explicitly rather than trusting the installed default. +- **Set `pull.ff only` explicitly** — do not trust the installed default. ## Step 1 — Clear the force-push gate diff --git a/plugins/git/skills/git-remotes/references/pull.md b/plugins/git/skills/git-remotes/references/pull.md index 3a8a25a..52be576 100644 --- a/plugins/git/skills/git-remotes/references/pull.md +++ b/plugins/git/skills/git-remotes/references/pull.md @@ -23,6 +23,10 @@ A pull that diverges with no strategy configured fails, and that failure is the ## Config precedence +The installed default varies by Git version — older versions merge on divergence, newer ones +default to `--ff-only` — so an unset `pull.ff` means the same pull behaves differently on different +machines. Set it explicitly. + Highest wins: 1. Command-line flag (`--ff-only` / `--rebase` / `--no-rebase`) diff --git a/plugins/git/skills/git-submodules/SKILL.md b/plugins/git/skills/git-submodules/SKILL.md index 60b86b4..e274475 100644 --- a/plugins/git/skills/git-submodules/SKILL.md +++ b/plugins/git/skills/git-submodules/SKILL.md @@ -51,7 +51,7 @@ executing. ## Output format -``` +```yaml operation: <clone|add|init|update|status|sync|set-url|set-branch|absorbgitdirs|deinit|remove> status: <success|error|partial> message: <one line; include git's own output on error> diff --git a/plugins/git/skills/git-submodules/references/README.md b/plugins/git/skills/git-submodules/references/README.md index 244de95..19b9784 100644 --- a/plugins/git/skills/git-submodules/references/README.md +++ b/plugins/git/skills/git-submodules/references/README.md @@ -1,7 +1,6 @@ --- -metadata: - source_keys: - - git-scm-submodule-docs +source_keys: + - git-scm-submodule-docs --- # References diff --git a/plugins/git/skills/git-workflow/SKILL.md b/plugins/git/skills/git-workflow/SKILL.md index 5dd21a8..e7e5034 100644 --- a/plugins/git/skills/git-workflow/SKILL.md +++ b/plugins/git/skills/git-workflow/SKILL.md @@ -2,9 +2,9 @@ name: git-workflow description: > - Use when a human wants to work through local git interactively — commits, branches, history, - submodules, worktrees, or remotes. Not an agent caller needing deterministic execution -> - `git-orchestrate`. Not server-side Gitea work -> `gitea-workflow`. + Use when a human's local git request is general or ambiguous — it routes to the owning + domain skill. Not an unambiguous commit -> `git-commits`. Not an unambiguous branch -> + `git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. metadata: category: git diff --git a/plugins/git/skills/git-worktrees/SKILL.md b/plugins/git/skills/git-worktrees/SKILL.md index f69223c..f89dcfe 100644 --- a/plugins/git/skills/git-worktrees/SKILL.md +++ b/plugins/git/skills/git-worktrees/SKILL.md @@ -28,7 +28,7 @@ metadata: | Create on a new branch | `git worktree add -b <branch> <path>` | | Create on the branch named after the path basename | `git worktree add <path>` — checks that branch out if it exists, else creates it from HEAD | | Create and reset an existing branch to HEAD — discards its commits | `git worktree add -B <branch> <path>` | -| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** when `<branch>` has no local copy (gate below) | +| Create a local branch tracking a remote one | `git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` | | Throwaway experiment, no branch | `git worktree add -d <path>` — detached HEAD | | **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above | | List | `git worktree list -v`, or `--porcelain -z` to parse | @@ -40,15 +40,14 @@ metadata: If the operation needs anything the table does not carry — the full `add` flag table, orphan branches, sparse-checkout, locking for removable media, remote -disambiguation across several remotes, worktree config keys, or the worked -emergency-fix and PR-review patterns — read `references/worktrees.md`. +disambiguation across several remotes, how to name a worktree unambiguously, +worktree config keys, or the worked emergency-fix and PR-review patterns — read +`references/worktrees.md`. Gates: - **`move`, `remove` — the main worktree cannot be moved or removed.** Only linked worktrees, the ones `git worktree add` created, are candidates. - **`add`, `move`, `remove` — escalate force flags one step at a time.** `-f` overrides a safeguard such as an unclean tree; `move` and `remove` need `-ff` on top of that when the worktree is locked. Confirm with the user before either — both discard state. -- **`lock`, `move`, `remove`, `repair` — identify a worktree by full path, unique basename, or unique partial path.** An ambiguous name errors rather than picking; `git worktree list` shows the usable identifiers. -- **`add` — the bare-name tracking shortcut needs exactly one remote.** `git worktree add <path> <branch>` sets up tracking only when `<branch>` is absent locally, no `-b`/`-B`/`-d` is given, and exactly one remote carries the name. With several, it fires only if `checkout.defaultRemote` names one. When the remote is ambiguous or unknown, use `--track -b`. - **`add` — lock at creation, not after.** `git worktree add --lock` is atomic, where add-then-`lock` leaves a window in which the worktree is unprotected. ## Step 2 — Report diff --git a/plugins/git/skills/git-worktrees/references/worktrees.md b/plugins/git/skills/git-worktrees/references/worktrees.md index a5cedef..64172f1 100644 --- a/plugins/git/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/skills/git-worktrees/references/worktrees.md @@ -4,6 +4,8 @@ source_keys: - git-scm-worktree-docs --- +# Git worktrees + ## Shared vs. per-worktree state All worktrees share one object store, one config, and most refs under `refs/`. Each worktree keeps @@ -12,6 +14,12 @@ its own `HEAD`, index, and per-worktree metadata (`ORIG_HEAD`, `MERGE_HEAD`, `re worktree** exists per repo — the one `git init` or `git clone` produced — and it cannot be removed or moved. Every other worktree is a **linked worktree** created by `git worktree add`. +## Identifying a worktree + +`lock`, `move`, `remove` and `repair` accept a full path, a unique basename, or a unique partial +path. An ambiguous name errors rather than picking one; `git worktree list` shows the identifiers +that are usable. + ## `add` forms ```bash @@ -85,10 +93,11 @@ git worktree unlock <path> # when reconnected git worktree add --track -b <branch> <path> <remote>/<branch> # explicit: no guessing at all git worktree add <path> <branch> # shortcut: needs one clear remote ``` -The shortcut fires only when `<branch>` is not found locally, none of `-b`/`-B`/`--detach` were -given, and a tracking branch of that name exists in exactly one remote. When several remotes carry -the name, `checkout.defaultRemote` picks one for disambiguation purposes; with no such setting the -shortcut has no single remote to resolve against and does not apply. +**The bare-name shortcut needs exactly one remote.** It fires only when `<branch>` is not found +locally, none of `-b`/`-B`/`--detach` were given, and a tracking branch of that name exists in +exactly one remote. When several remotes carry the name, `checkout.defaultRemote` picks one for +disambiguation purposes; with no such setting the shortcut has no single remote to resolve against +and does not apply. When the remote is ambiguous or unknown, use the explicit `--track -b` form. `--guess-remote` covers the *other* spelling — `git worktree add <path>` with no `<commit-ish>` at all. It bases the new branch on the remote-tracking branch matching `$(basename <path>)` when diff --git a/plugins/git/skills/pc-run/SKILL.md b/plugins/git/skills/pc-run/SKILL.md index 719b1ab..bc29166 100644 --- a/plugins/git/skills/pc-run/SKILL.md +++ b/plugins/git/skills/pc-run/SKILL.md @@ -20,7 +20,7 @@ allowed-tools: Bash Read - The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring. - Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead. -- `- files were modified by this hook` is not a bug. A fixer hook (`trailing-whitespace`, `end-of-file-fixer`, `pretty-format-json --autofix`) rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. The fix is to re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — that flag overwrites hook files in `.git/hooks/` and has nothing to do with re-staging. +- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `git add -u && git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging. ## Gate — `pre-commit clean` @@ -47,8 +47,6 @@ Determine intent from the user's request, then execute the matching operation. W | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | | "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | -If the intent is ambiguous, default to `pre-commit run --all-files`. - ## Run Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. diff --git a/plugins/git/skills/pc-run/references/clean.md b/plugins/git/skills/pc-run/references/clean.md index 0ab452b..4fa6c44 100644 --- a/plugins/git/skills/pc-run/references/clean.md +++ b/plugins/git/skills/pc-run/references/clean.md @@ -6,16 +6,12 @@ source_keys: # Wiping the pre-commit cache -Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. Self-contained. +Reached from `SKILL.md`'s Route table when the user asks to clean the cache or rebuild environments from scratch. The confirmation gate on `pre-commit clean` stays in `SKILL.md`, because it must fire on every path that reaches this command, not only this one. -## Gate — confirm first +## Gate -`pre-commit clean` wipes the whole cache at `~/.cache/pre-commit`, forcing every hook environment to be re-downloaded on the next run. Require explicit confirmation before executing it: - -> "This will wipe the entire pre-commit cache. All hook environments will be re-downloaded on next run. Proceed?" +`pre-commit clean` runs only after the confirmation gate in `SKILL.md` clears — that gate, its exact wording, and the `pre-commit gc` alternative live there and are not restated here. ```bash pre-commit clean ``` - -Prefer `pre-commit gc` when the goal is only to reclaim disk — it drops unused environments and leaves the ones in use intact, so it needs no confirmation. -- 2.43.0 From 1c3af756427bbb4fb1651c06d1e410f294f1accb Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:01:45 +0000 Subject: [PATCH 64/89] fix(core): make --no-import-syntax actually change the adapter validation Both branches of the flag reduced to the same expression, so the option was inert: a caller who asked for the no-import form got the import-form check anyway and a passing result that meant nothing. Two further defects in the same validator: --max-lines failed silently when given a value it could not use, and the Fix text told the agent to edit AGENTS.md when the offending content is the provider adapter's. The boundary clauses now name the operation being routed rather than the file type, which was ambiguous where both skills touch the same file. Addresses #115. --- .../core/.apm/skills/agentsmd-audit/README.md | 6 +-- .../core/.apm/skills/agentsmd-audit/SKILL.md | 24 +++------- .../core/.apm/skills/agentsmd-author/SKILL.md | 6 +-- .../skills/provider-adapter-author/README.md | 2 +- .../skills/provider-adapter-author/SKILL.md | 6 +-- .../scripts/validate-adapter.sh | 37 ++++++++++----- .../tests/validate-adapter.bats | 45 +++++++++++++++++++ plugins/core/skills/agentsmd-audit/README.md | 6 +-- plugins/core/skills/agentsmd-audit/SKILL.md | 24 +++------- plugins/core/skills/agentsmd-author/SKILL.md | 6 +-- .../skills/provider-adapter-author/README.md | 2 +- .../skills/provider-adapter-author/SKILL.md | 6 +-- .../scripts/validate-adapter.sh | 37 ++++++++++----- 13 files changed, 131 insertions(+), 76 deletions(-) diff --git a/plugins/core/.apm/skills/agentsmd-audit/README.md b/plugins/core/.apm/skills/agentsmd-audit/README.md index 658df6f..dec17e6 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/README.md +++ b/plugins/core/.apm/skills/agentsmd-audit/README.md @@ -33,6 +33,6 @@ hand-edit made outside `agentsmd-author` — the audit is what confirms the resu | `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are -not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` -when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to -discover (ADR-0017). Run them from a repo checkout, not from an install. +not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips +`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no +plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md index 8d578d4..e852ebd 100644 --- a/plugins/core/.apm/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-audit/SKILL.md @@ -1,10 +1,10 @@ --- name: agentsmd-audit description: > - Use when the user wants a repo's AGENTS.md audited — "audit this AGENTS.md", - "is this AGENTS.md safe to commit" — or after a hand-edit outside - `agentsmd-author`. Reports secrets, structure and drift; never edits. - Not for CLAUDE.md or provider files -> `provider-adapter-author`. + Use when the user wants a repo's AGENTS.md audited for secrets, structure + and drift — "is this AGENTS.md safe to commit" — or after a hand-edit + outside `agentsmd-author`. + Not converting a provider file -> `provider-adapter-author`. Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: @@ -31,7 +31,7 @@ bash scripts/validate-structure.sh <repo-root> bash scripts/validate-drift.sh <repo-root> ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL` lines, plus `INFO`/`SUGGESTION` where applicable, with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report @@ -43,16 +43,4 @@ Checked: secrets · structure · drift Then output only findings that were found, in this order within a repo: `### Secrets`, `### Structure`, `### Drift`. Omit a dimension heading entirely if it produced nothing — its absence confirms it passed. Report each finding verbatim as emitted by the scripts (they already carry file:line, Why/Fix or Note). -Close with a result block: - -```text -## Result - -PASS -PASS · P info -PASS (N suggestions) · P info -FAIL (N fails) -FAIL (N fails) · P info -``` - -INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. Omit the suggestion count when there are none, and omit `· P info` when there are none. INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. diff --git a/plugins/core/.apm/skills/agentsmd-author/SKILL.md b/plugins/core/.apm/skills/agentsmd-author/SKILL.md index 6dc9d1f..a9b35f3 100644 --- a/plugins/core/.apm/skills/agentsmd-author/SKILL.md +++ b/plugins/core/.apm/skills/agentsmd-author/SKILL.md @@ -3,7 +3,7 @@ name: agentsmd-author description: > Use when the user wants a repo's AGENTS.md written or updated, root or nested, including "document this for AI coding tools". Writes only verified - conventions. Not review-only -> `agentsmd-audit`. Not for CLAUDE.md -> + conventions. Not review-only -> `agentsmd-audit`. Not converting CLAUDE.md -> `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: @@ -36,8 +36,8 @@ AGENTS.md has no required schema. Use only sections that reflect something real ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists, invoke the `provider-adapter-author` skill on it to reconcile — whether it duplicates content the AGENTS.md you just wrote/updated now owns, or is merely stale or missing a pointer to it. Never edit it yourself in either case. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill on the target repo root — its validators take a `<repo-root>` and walk the tree for every AGENTS.md themselves; there is no per-file entry point. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. diff --git a/plugins/core/.apm/skills/provider-adapter-author/README.md b/plugins/core/.apm/skills/provider-adapter-author/README.md index 1f665e0..9401197 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/README.md @@ -31,6 +31,6 @@ Provide the path to the provider-specific file to convert (and the target repo r | `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`) -but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips +but are not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 2de7fe5..76111c7 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -2,7 +2,8 @@ name: provider-adapter-author description: > Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) - duplicating the repo's AGENTS.md should be cut to a thin adapter. + duplicating the repo's AGENTS.md should be cut to a thin adapter — "make + CLAUDE.md just import AGENTS.md". Not writing the AGENTS file -> `agentsmd-author`. Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write @@ -16,7 +17,6 @@ metadata: ## Gotchas - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. -- Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. ## Step 1 — Detect @@ -43,7 +43,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. The size `FAIL` advises moving provider-agnostic content into `AGENTS.md`; disregard that half of its wording and delete the redundant lines instead. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh index a94d112..513ef1f 100755 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -16,10 +16,15 @@ Arguments: Options: --no-import-syntax The target provider has no native cross-file import - mechanism. Accept a plain-text pointer mention of - "AGENTS.md" instead of requiring an @import-style line. + mechanism. Require a plain-text pointer line naming + "AGENTS.md" instead of an @import-style line; an + @AGENTS.md line alone does not satisfy it, because + such a provider never resolves it. Without this flag + an actual @import line is required, and naming + AGENTS.md in prose alone does not satisfy it. --max-lines N Max non-blank lines allowed in the adapter file before - it's considered no longer "thin". Default: 60. + it's considered no longer "thin". Must be a + non-negative integer. Default: 60. --help, -h Show this help and exit 0. Exit codes: @@ -44,7 +49,15 @@ while [[ $# -gt 0 ]]; do shift ;; --max-lines) - MAX_LINES="${2:-}" + if [[ $# -lt 2 ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 1 + fi + MAX_LINES="$2" + if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 + exit 1 + fi shift 2 ;; *) @@ -94,21 +107,25 @@ if not adapter_content.strip(): IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') lines = adapter_content.splitlines() import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] +# A prose pointer is any line naming AGENTS.md that is not itself an import +# line — an inert `@AGENTS.md` in a provider that resolves no imports points +# a reader at nothing. +pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln] if no_import_syntax: - has_reference = "AGENTS.md" in adapter_content + has_reference = bool(pointer_lines) else: - has_reference = bool(import_lines) or "AGENTS.md" in adapter_content + has_reference = bool(import_lines) if not has_reference: has_fail = True print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}") if no_import_syntax: - print(" Why: This provider has no import syntax, so the adapter must at least mention AGENTS.md as a text pointer.") + print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md (e.g. `@AGENTS.md`) rather than silently omitting it.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") + print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check --- @@ -132,7 +149,7 @@ if non_blank_count > max_lines: has_fail = True print(f"FAIL Adapter is not thin — {adapter_path}") print(f" Why: {non_blank_count} non-blank lines exceeds the {max_lines}-line threshold for a thin adapter.") - print(" Fix: Move provider-agnostic content into AGENTS.md; keep only genuinely provider-specific additions here.") + print(" Fix: Delete the lines already covered by AGENTS.md; keep only genuinely provider-specific additions here.") print() if has_fail: diff --git a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats index 3b5252c..e1277aa 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats +++ b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats @@ -115,6 +115,51 @@ EOF assert_output --partial "no reference" } +@test "the two --no-import-syntax branches disagree: a text-pointer-only adapter fails in default mode" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +See AGENTS.md at the repo root for setup, style, and testing conventions. + +## Copilot-specific +Prefer inline suggestions over chat for one-line edits. +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure + assert_output --partial "no reference" + + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "with --no-import-syntax, an inert @AGENTS.md line alone is not a prose pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +@AGENTS.md + +## Copilot-specific +Prefer inline suggestions over chat for one-line edits. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure + assert_output --partial "no reference" +} + +@test "--max-lines as the final argument reports a real error instead of failing silently" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" --max-lines + assert_failure + assert_output --partial "--max-lines requires a value" +} + +@test "--max-lines rejects a non-numeric value with a real error" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" --max-lines abc "$ADAPTER" "$AGENTS_MD" + assert_failure + assert_output --partial "non-negative integer" +} + @test "--help exits 0 and documents usage" { run bash "$SCRIPT" --help assert_success diff --git a/plugins/core/skills/agentsmd-audit/README.md b/plugins/core/skills/agentsmd-audit/README.md index 658df6f..dec17e6 100644 --- a/plugins/core/skills/agentsmd-audit/README.md +++ b/plugins/core/skills/agentsmd-audit/README.md @@ -33,6 +33,6 @@ hand-edit made outside `agentsmd-author` — the audit is what confirms the resu | `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are -not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` -when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to -discover (ADR-0017). Run them from a repo checkout, not from an install. +not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips +`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no +plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/skills/agentsmd-audit/SKILL.md b/plugins/core/skills/agentsmd-audit/SKILL.md index 8d578d4..e852ebd 100644 --- a/plugins/core/skills/agentsmd-audit/SKILL.md +++ b/plugins/core/skills/agentsmd-audit/SKILL.md @@ -1,10 +1,10 @@ --- name: agentsmd-audit description: > - Use when the user wants a repo's AGENTS.md audited — "audit this AGENTS.md", - "is this AGENTS.md safe to commit" — or after a hand-edit outside - `agentsmd-author`. Reports secrets, structure and drift; never edits. - Not for CLAUDE.md or provider files -> `provider-adapter-author`. + Use when the user wants a repo's AGENTS.md audited for secrets, structure + and drift — "is this AGENTS.md safe to commit" — or after a hand-edit + outside `agentsmd-author`. + Not converting a provider file -> `provider-adapter-author`. Not writing AGENTS.md -> `agentsmd-author`. allowed-tools: Bash Read metadata: @@ -31,7 +31,7 @@ bash scripts/validate-structure.sh <repo-root> bash scripts/validate-drift.sh <repo-root> ``` -Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. +Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL` lines, plus `INFO`/`SUGGESTION` where applicable, with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand. Grade a manual finding the way the scripts grade theirs: a missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference. ## Step 2 — Report @@ -43,16 +43,4 @@ Checked: secrets · structure · drift Then output only findings that were found, in this order within a repo: `### Secrets`, `### Structure`, `### Drift`. Omit a dimension heading entirely if it produced nothing — its absence confirms it passed. Report each finding verbatim as emitted by the scripts (they already carry file:line, Why/Fix or Note). -Close with a result block: - -```text -## Result - -PASS -PASS · P info -PASS (N suggestions) · P info -FAIL (N fails) -FAIL (N fails) · P info -``` - -INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. Omit the suggestion count when there are none, and omit `· P info` when there are none. INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes. diff --git a/plugins/core/skills/agentsmd-author/SKILL.md b/plugins/core/skills/agentsmd-author/SKILL.md index 6dc9d1f..a9b35f3 100644 --- a/plugins/core/skills/agentsmd-author/SKILL.md +++ b/plugins/core/skills/agentsmd-author/SKILL.md @@ -3,7 +3,7 @@ name: agentsmd-author description: > Use when the user wants a repo's AGENTS.md written or updated, root or nested, including "document this for AI coding tools". Writes only verified - conventions. Not review-only -> `agentsmd-audit`. Not for CLAUDE.md -> + conventions. Not review-only -> `agentsmd-audit`. Not converting CLAUDE.md -> `provider-adapter-author`. allowed-tools: Bash Read Write Edit metadata: @@ -36,8 +36,8 @@ AGENTS.md has no required schema. Use only sections that reflect something real ## Step 4 — Check for an existing provider file -Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile. +Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists, invoke the `provider-adapter-author` skill on it to reconcile — whether it duplicates content the AGENTS.md you just wrote/updated now owns, or is merely stale or missing a pointer to it. Never edit it yourself in either case. ## Step 5 — Audit and report -Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. +Invoke the `agentsmd-audit` skill on the target repo root — its validators take a `<repo-root>` and walk the tree for every AGENTS.md themselves; there is no per-file entry point. This closeout is mandatory, not optional, even when the change looks trivial — never sign the work off on your own judgment. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result. diff --git a/plugins/core/skills/provider-adapter-author/README.md b/plugins/core/skills/provider-adapter-author/README.md index 1f665e0..9401197 100644 --- a/plugins/core/skills/provider-adapter-author/README.md +++ b/plugins/core/skills/provider-adapter-author/README.md @@ -31,6 +31,6 @@ Provide the path to the provider-specific file to convert (and the target repo r | `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` | Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`) -but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips +but are not present in an installed plugin: the repo's `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 2de7fe5..76111c7 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -2,7 +2,8 @@ name: provider-adapter-author description: > Use when a provider file (CLAUDE.md, .cursor rules, copilot-instructions) - duplicating the repo's AGENTS.md should be cut to a thin adapter. + duplicating the repo's AGENTS.md should be cut to a thin adapter — "make + CLAUDE.md just import AGENTS.md". Not writing the AGENTS file -> `agentsmd-author`. Not auditing the AGENTS file -> `agentsmd-audit`. allowed-tools: Bash Read Edit Write @@ -16,7 +17,6 @@ metadata: ## Gotchas - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. -- Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists. ## Step 1 — Detect @@ -43,7 +43,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. The size `FAIL` advises moving provider-agnostic content into `AGENTS.md`; disregard that half of its wording and delete the redundant lines instead. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh index a94d112..513ef1f 100755 --- a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -16,10 +16,15 @@ Arguments: Options: --no-import-syntax The target provider has no native cross-file import - mechanism. Accept a plain-text pointer mention of - "AGENTS.md" instead of requiring an @import-style line. + mechanism. Require a plain-text pointer line naming + "AGENTS.md" instead of an @import-style line; an + @AGENTS.md line alone does not satisfy it, because + such a provider never resolves it. Without this flag + an actual @import line is required, and naming + AGENTS.md in prose alone does not satisfy it. --max-lines N Max non-blank lines allowed in the adapter file before - it's considered no longer "thin". Default: 60. + it's considered no longer "thin". Must be a + non-negative integer. Default: 60. --help, -h Show this help and exit 0. Exit codes: @@ -44,7 +49,15 @@ while [[ $# -gt 0 ]]; do shift ;; --max-lines) - MAX_LINES="${2:-}" + if [[ $# -lt 2 ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 + exit 1 + fi + MAX_LINES="$2" + if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 + exit 1 + fi shift 2 ;; *) @@ -94,21 +107,25 @@ if not adapter_content.strip(): IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') lines = adapter_content.splitlines() import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] +# A prose pointer is any line naming AGENTS.md that is not itself an import +# line — an inert `@AGENTS.md` in a provider that resolves no imports points +# a reader at nothing. +pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln] if no_import_syntax: - has_reference = "AGENTS.md" in adapter_content + has_reference = bool(pointer_lines) else: - has_reference = bool(import_lines) or "AGENTS.md" in adapter_content + has_reference = bool(import_lines) if not has_reference: has_fail = True print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}") if no_import_syntax: - print(" Why: This provider has no import syntax, so the adapter must at least mention AGENTS.md as a text pointer.") + print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md (e.g. `@AGENTS.md`) rather than silently omitting it.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") + print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check --- @@ -132,7 +149,7 @@ if non_blank_count > max_lines: has_fail = True print(f"FAIL Adapter is not thin — {adapter_path}") print(f" Why: {non_blank_count} non-blank lines exceeds the {max_lines}-line threshold for a thin adapter.") - print(" Fix: Move provider-agnostic content into AGENTS.md; keep only genuinely provider-specific additions here.") + print(" Fix: Delete the lines already covered by AGENTS.md; keep only genuinely provider-specific additions here.") print() if has_fail: -- 2.43.0 From 03abcffb770716e967bb107ae309168aa510cb37 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:03 +0000 Subject: [PATCH 65/89] refactor(bin): cut per-invocation load and repair broken skill references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diagnose read its feedback-loops reference unconditionally, so every invocation paid for guidance most runs never used; the read is conditional again and the per-invocation cost drops from 1,278 to 831 words. Its HITL template moves to assets/ because it is copied out, not read as reference. prototype's two branch flows move into references/ for the same reason — only one branch is ever taken. research could not search the codebase it was asked to research without Grep and Glob. caveman's description had grown into a paragraph where one sentence carries the trigger. Four references pointed at things that do not exist: a to-prd skill, a /setup-matt-pocock-skills command, two cross-skill ../ links that only resolve in the source tree, and two places calling this project's Gitea host GitHub. Addresses #114. --- plugins/bin/.apm/skills/caveman/SKILL.md | 8 +++---- plugins/bin/.apm/skills/diagnose/SKILL.md | 12 +++++----- .../{scripts => assets}/hitl-loop.template.sh | 0 .../diagnose/references/feedback-loops.md | 2 +- .../diagnose/references/regression-seams.md | 24 +++++++++++++++++++ .../improve-codebase-architecture/SKILL.md | 4 ++-- plugins/bin/.apm/skills/prototype/SKILL.md | 12 ++++++---- .../{LOGIC.md => references/logic.md} | 5 +--- .../prototype/{UI.md => references/ui.md} | 3 +-- plugins/bin/.apm/skills/research/SKILL.md | 8 ++++--- plugins/bin/.apm/skills/triage/AGENT-BRIEF.md | 4 ++-- plugins/bin/.apm/skills/triage/SKILL.md | 2 +- plugins/bin/.apm/skills/write-docs/SKILL.md | 4 ++-- plugins/bin/skills/caveman/SKILL.md | 8 +++---- plugins/bin/skills/diagnose/SKILL.md | 12 +++++----- .../{scripts => assets}/hitl-loop.template.sh | 0 .../diagnose/references/feedback-loops.md | 2 +- .../diagnose/references/regression-seams.md | 24 +++++++++++++++++++ .../improve-codebase-architecture/SKILL.md | 4 ++-- plugins/bin/skills/prototype/SKILL.md | 12 ++++++---- .../{LOGIC.md => references/logic.md} | 5 +--- .../prototype/{UI.md => references/ui.md} | 3 +-- plugins/bin/skills/research/SKILL.md | 8 ++++--- plugins/bin/skills/triage/AGENT-BRIEF.md | 4 ++-- plugins/bin/skills/triage/SKILL.md | 2 +- plugins/bin/skills/write-docs/SKILL.md | 4 ++-- 26 files changed, 110 insertions(+), 66 deletions(-) rename plugins/bin/.apm/skills/diagnose/{scripts => assets}/hitl-loop.template.sh (100%) create mode 100644 plugins/bin/.apm/skills/diagnose/references/regression-seams.md rename plugins/bin/.apm/skills/prototype/{LOGIC.md => references/logic.md} (94%) rename plugins/bin/.apm/skills/prototype/{UI.md => references/ui.md} (96%) rename plugins/bin/skills/diagnose/{scripts => assets}/hitl-loop.template.sh (100%) create mode 100644 plugins/bin/skills/diagnose/references/regression-seams.md rename plugins/bin/skills/prototype/{LOGIC.md => references/logic.md} (94%) rename plugins/bin/skills/prototype/{UI.md => references/ui.md} (96%) diff --git a/plugins/bin/.apm/skills/caveman/SKILL.md b/plugins/bin/.apm/skills/caveman/SKILL.md index a4bb498..dea5b4a 100644 --- a/plugins/bin/.apm/skills/caveman/SKILL.md +++ b/plugins/bin/.apm/skills/caveman/SKILL.md @@ -2,17 +2,15 @@ name: caveman disable-model-invocation: true description: > - Ultra-compressed output mode: drops articles, filler and pleasantries while - keeping technical substance exact. Cuts token usage by roughly 75%. Hand-invoked - only — type /caveman to turn it on, "stop caveman" or "normal mode" to turn it - off. Stays active across turns until you do. + Ultra-compressed output mode that drops articles, filler and pleasantries while + keeping technical substance exact, cutting token usage by roughly 75%. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. ## Persistence -ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". +ACTIVE EVERY RESPONSE once user type `/caveman`. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". ## Rules diff --git a/plugins/bin/.apm/skills/diagnose/SKILL.md b/plugins/bin/.apm/skills/diagnose/SKILL.md index 3de68ca..1e60479 100644 --- a/plugins/bin/.apm/skills/diagnose/SKILL.md +++ b/plugins/bin/.apm/skills/diagnose/SKILL.md @@ -18,7 +18,9 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -Read `references/feedback-loops.md` — even if you already have a signal. Ten ways to build a loop ordered by cost, how to sharpen the one you have, and what to do when the bug resists reproduction. An unsharpened loop is usually not good enough yet. +**If you do not yet have such a signal, read `references/feedback-loops.md`** — ten ways to build one ordered by cost, and what to ask the user for when the bug resists reproduction entirely. + +**If you do have one, it is probably not sharp enough yet.** Make it faster and more deterministic, and make it assert on the exact symptom rather than "didn't crash" — a 30-second flaky loop is barely better than no loop. If it stays slow or intermittent after that, read that file's "Iterate on the loop itself" and "Intermittent bugs" sections. Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. @@ -62,13 +64,11 @@ Tool preference: ## Phase 5 — Fix + regression test -Write the regression test **before the fix** — but only if there is a **correct seam** for it. +Write the regression test **before the fix** — but only at a **correct seam**: one where the test exercises the real bug pattern as it occurs at the call site. If the available seam looks too shallow, or you cannot tell whether it is, read `references/regression-seams.md`. -A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. +**If no correct seam exists, that itself is the finding.** Note it and carry it into Phase 6 — the architecture is preventing the bug from being locked down. -**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. - -If a correct seam exists: +At a correct seam: 1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. diff --git a/plugins/bin/.apm/skills/diagnose/scripts/hitl-loop.template.sh b/plugins/bin/.apm/skills/diagnose/assets/hitl-loop.template.sh similarity index 100% rename from plugins/bin/.apm/skills/diagnose/scripts/hitl-loop.template.sh rename to plugins/bin/.apm/skills/diagnose/assets/hitl-loop.template.sh diff --git a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md index d3d83c2..8804543 100644 --- a/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md +++ b/plugins/bin/.apm/skills/diagnose/references/feedback-loops.md @@ -13,7 +13,7 @@ A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for th 7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. 8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. 9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `assets/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. ## Iterate on the loop itself diff --git a/plugins/bin/.apm/skills/diagnose/references/regression-seams.md b/plugins/bin/.apm/skills/diagnose/references/regression-seams.md new file mode 100644 index 0000000..26176a8 --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/references/regression-seams.md @@ -0,0 +1,24 @@ +# Judging a regression-test seam + +Read this when Phase 5 leaves you unsure whether the seam available for the regression test is the correct one — either because the obvious seam looks shallow, or because there appears to be no seam at all. + +## What makes a seam correct + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site: the same entry point, the same participants, the same ordering, and the same state the real caller holds when it goes wrong. + +## Seams that are too shallow + +- A single-caller test when the bug only appears with multiple callers. +- A unit test that cannot replicate the chain of calls that triggered the bug. +- A test that reproduces the symptom by construction — asserting on a value the test itself set — rather than by driving the code path that produces it. +- A test that mocks out the collaborator the bug actually lives in. + +A regression test at a shallow seam gives false confidence. It passes forever, including after a change reintroduces the bug at the real call site, and it will be read by the next maintainer as proof the bug is locked down. + +## When there is no correct seam + +Do not force one, and do not settle for a shallow seam to have something green. Instead: + +1. Apply the fix and verify it against the Phase 1 loop directly. +2. Write down which seams you considered and why each was too shallow. +3. Carry that into Phase 6's "what would have prevented this bug" question. A missing seam is an architecture finding — tangled callers, hidden coupling, or a module with no testable boundary — and the handoff is the `improve-codebase-architecture` skill, with those specifics attached. diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md index 05984a6..22d02fb 100644 --- a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md @@ -65,7 +65,7 @@ Once the user picks a candidate, drop into a grilling conversation. Walk the des Side effects happen inline as decisions crystallize: -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. - **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/plugins/bin/.apm/skills/prototype/SKILL.md b/plugins/bin/.apm/skills/prototype/SKILL.md index e938808..0c9d8e3 100644 --- a/plugins/bin/.apm/skills/prototype/SKILL.md +++ b/plugins/bin/.apm/skills/prototype/SKILL.md @@ -3,8 +3,8 @@ name: prototype description: > Use when the user wants a throwaway prototype to answer a design question about a data model, state machine or business logic, or to mock up a UI in several - variations, as in "try a few designs". Not production code -> `tdd`. Not - talking a design through without building -> `grill-me`. + variations. Not production code -> `tdd`. Not talking a design through -> + `grill-me`. --- # Prototype @@ -13,10 +13,12 @@ A prototype is **throwaway code that answers a question**. The question decides ## Pick a branch -Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: +| Question being answered | Build | Reference | +|---|---|---| +| "Does this logic / state model feel right?" | A tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper | `references/logic.md` | +| "What should this look like?" | Several radically different UI variations on one route, switchable via a URL search param and a floating bottom bar | `references/ui.md` | -- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. -- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. +Resolve the row from the user's prompt, the surrounding code, or by asking if the user is around, then read only that reference — each is self-contained. The two branches produce fundamentally different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. diff --git a/plugins/bin/.apm/skills/prototype/LOGIC.md b/plugins/bin/.apm/skills/prototype/references/logic.md similarity index 94% rename from plugins/bin/.apm/skills/prototype/LOGIC.md rename to plugins/bin/.apm/skills/prototype/references/logic.md index 526ecb1..2945853 100644 --- a/plugins/bin/.apm/skills/prototype/LOGIC.md +++ b/plugins/bin/.apm/skills/prototype/references/logic.md @@ -9,7 +9,7 @@ A tiny interactive terminal app that lets the user drive a state model by hand. - "I want to feel out what the API should look like before writing it." - Anything where the user wants to **press buttons and watch state change**. -If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). +If the question is "what should this look like" — wrong branch. Read `references/ui.md`. ## Process @@ -72,8 +72,5 @@ When the prototype has done its job, the answer to the question is the only thin ## Anti-patterns -- **Don't add tests.** A prototype that needs tests is no longer a prototype. -- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. -- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. - **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. - **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/plugins/bin/.apm/skills/prototype/UI.md b/plugins/bin/.apm/skills/prototype/references/ui.md similarity index 96% rename from plugins/bin/.apm/skills/prototype/UI.md rename to plugins/bin/.apm/skills/prototype/references/ui.md index f3b6e64..4de17de 100644 --- a/plugins/bin/.apm/skills/prototype/UI.md +++ b/plugins/bin/.apm/skills/prototype/references/ui.md @@ -2,7 +2,7 @@ Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. -If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). +If the question is about logic/state rather than what something looks like — wrong branch. Read `references/logic.md`. ## When this is the right shape @@ -109,4 +109,3 @@ Don't leave variant components or the switcher lying around. They rot fast and c - **Variants that differ only in colour or copy.** That's a tweak, not a prototype. Real variants disagree about structure. - **Sharing too much code between variants.** A shared `<Header>` is fine; a shared `<Layout>` defeats the point. Each variant should be free to throw out the layout. - **Wiring variants to real mutations.** Read-only prototypes are fine. If a variant needs to mutate, point it at a stub — the question is "what should this look like", not "does the backend work". -- **Promoting the prototype directly to production.** The variant code was written under prototype constraints (no tests, minimal error handling). Rewrite it properly when you fold it in. diff --git a/plugins/bin/.apm/skills/research/SKILL.md b/plugins/bin/.apm/skills/research/SKILL.md index 2ab6459..1779011 100644 --- a/plugins/bin/.apm/skills/research/SKILL.md +++ b/plugins/bin/.apm/skills/research/SKILL.md @@ -8,10 +8,12 @@ description: >- metadata: category: research allowed-tools: - - WebSearch - - WebFetch + - Grep + - Glob - Read - Write + - WebSearch + - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs model: sonnet @@ -61,4 +63,4 @@ Merge every set of notes, Context7 and web alike, by topic area. Read `reference Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. A directory of empty files is worse than an honest miss. +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md b/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md index 2efecdf..a7e1aa8 100644 --- a/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md +++ b/plugins/bin/.apm/skills/triage/AGENT-BRIEF.md @@ -1,6 +1,6 @@ # Writing Agent Briefs -An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. +An agent brief is a structured comment posted on an issue in the issue tracker when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. ## Principles @@ -27,7 +27,7 @@ Describe **what** the system should do, not **how** to implement it. The agent w The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. -- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Good:** "Querying the issue tracker for the `needs-triage` label returns issues that have been through initial classification" - **Bad:** "Triage should work correctly" ### Explicit scope boundaries diff --git a/plugins/bin/.apm/skills/triage/SKILL.md b/plugins/bin/.apm/skills/triage/SKILL.md index 3dee68f..0254fae 100644 --- a/plugins/bin/.apm/skills/triage/SKILL.md +++ b/plugins/bin/.apm/skills/triage/SKILL.md @@ -35,7 +35,7 @@ Five **state** roles: Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. -These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. +These are canonical role names — the actual label strings used in the issue tracker may differ. Resolve each canonical name against the tracker's live label set before applying it, using whichever tracker skill this install provides. If a name has no counterpart there, report the gap and ask the maintainer for the mapping — never substitute a guess. State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. diff --git a/plugins/bin/.apm/skills/write-docs/SKILL.md b/plugins/bin/.apm/skills/write-docs/SKILL.md index 0d2da2d..85289e9 100644 --- a/plugins/bin/.apm/skills/write-docs/SKILL.md +++ b/plugins/bin/.apm/skills/write-docs/SKILL.md @@ -35,7 +35,7 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `to-prd` or `grill-me` +- User wants a PRD, decision doc, or architecture proposal → `grill-me` or `grill-with-docs` - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -88,7 +88,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `to-prd`, `grill-me`, or `grill-with-docs` +- Requested output is a PRD, decision doc, or architecture proposal → redirect to `grill-me` or `grill-with-docs` ## Self-check diff --git a/plugins/bin/skills/caveman/SKILL.md b/plugins/bin/skills/caveman/SKILL.md index a4bb498..dea5b4a 100644 --- a/plugins/bin/skills/caveman/SKILL.md +++ b/plugins/bin/skills/caveman/SKILL.md @@ -2,17 +2,15 @@ name: caveman disable-model-invocation: true description: > - Ultra-compressed output mode: drops articles, filler and pleasantries while - keeping technical substance exact. Cuts token usage by roughly 75%. Hand-invoked - only — type /caveman to turn it on, "stop caveman" or "normal mode" to turn it - off. Stays active across turns until you do. + Ultra-compressed output mode that drops articles, filler and pleasantries while + keeping technical substance exact, cutting token usage by roughly 75%. --- Respond terse like smart caveman. All technical substance stay. Only fluff die. ## Persistence -ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". +ACTIVE EVERY RESPONSE once user type `/caveman`. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". ## Rules diff --git a/plugins/bin/skills/diagnose/SKILL.md b/plugins/bin/skills/diagnose/SKILL.md index 3de68ca..1e60479 100644 --- a/plugins/bin/skills/diagnose/SKILL.md +++ b/plugins/bin/skills/diagnose/SKILL.md @@ -18,7 +18,9 @@ When exploring the codebase, use the project's domain glossary to get a clear me Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** -Read `references/feedback-loops.md` — even if you already have a signal. Ten ways to build a loop ordered by cost, how to sharpen the one you have, and what to do when the bug resists reproduction. An unsharpened loop is usually not good enough yet. +**If you do not yet have such a signal, read `references/feedback-loops.md`** — ten ways to build one ordered by cost, and what to ask the user for when the bug resists reproduction entirely. + +**If you do have one, it is probably not sharp enough yet.** Make it faster and more deterministic, and make it assert on the exact symptom rather than "didn't crash" — a 30-second flaky loop is barely better than no loop. If it stays slow or intermittent after that, read that file's "Iterate on the loop itself" and "Intermittent bugs" sections. Do not proceed to Phase 2 until you have a loop you believe in. If you cannot build one, stop and say so explicitly, listing what you tried — never hypothesise without a signal. @@ -62,13 +64,11 @@ Tool preference: ## Phase 5 — Fix + regression test -Write the regression test **before the fix** — but only if there is a **correct seam** for it. +Write the regression test **before the fix** — but only at a **correct seam**: one where the test exercises the real bug pattern as it occurs at the call site. If the available seam looks too shallow, or you cannot tell whether it is, read `references/regression-seams.md`. -A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. +**If no correct seam exists, that itself is the finding.** Note it and carry it into Phase 6 — the architecture is preventing the bug from being locked down. -**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. - -If a correct seam exists: +At a correct seam: 1. Turn the Phase 1 loop into a failing test at that seam, narrowed to the symptom captured in Phase 2. 2. Watch it fail. diff --git a/plugins/bin/skills/diagnose/scripts/hitl-loop.template.sh b/plugins/bin/skills/diagnose/assets/hitl-loop.template.sh similarity index 100% rename from plugins/bin/skills/diagnose/scripts/hitl-loop.template.sh rename to plugins/bin/skills/diagnose/assets/hitl-loop.template.sh diff --git a/plugins/bin/skills/diagnose/references/feedback-loops.md b/plugins/bin/skills/diagnose/references/feedback-loops.md index d3d83c2..8804543 100644 --- a/plugins/bin/skills/diagnose/references/feedback-loops.md +++ b/plugins/bin/skills/diagnose/references/feedback-loops.md @@ -13,7 +13,7 @@ A feedback loop is a fast, deterministic, agent-runnable pass/fail signal for th 7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. 8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. 9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. -10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `assets/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. ## Iterate on the loop itself diff --git a/plugins/bin/skills/diagnose/references/regression-seams.md b/plugins/bin/skills/diagnose/references/regression-seams.md new file mode 100644 index 0000000..26176a8 --- /dev/null +++ b/plugins/bin/skills/diagnose/references/regression-seams.md @@ -0,0 +1,24 @@ +# Judging a regression-test seam + +Read this when Phase 5 leaves you unsure whether the seam available for the regression test is the correct one — either because the obvious seam looks shallow, or because there appears to be no seam at all. + +## What makes a seam correct + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site: the same entry point, the same participants, the same ordering, and the same state the real caller holds when it goes wrong. + +## Seams that are too shallow + +- A single-caller test when the bug only appears with multiple callers. +- A unit test that cannot replicate the chain of calls that triggered the bug. +- A test that reproduces the symptom by construction — asserting on a value the test itself set — rather than by driving the code path that produces it. +- A test that mocks out the collaborator the bug actually lives in. + +A regression test at a shallow seam gives false confidence. It passes forever, including after a change reintroduces the bug at the real call site, and it will be read by the next maintainer as proof the bug is locked down. + +## When there is no correct seam + +Do not force one, and do not settle for a shallow seam to have something green. Instead: + +1. Apply the fix and verify it against the Phase 1 loop directly. +2. Write down which seams you considered and why each was too shallow. +3. Carry that into Phase 6's "what would have prevented this bug" question. A missing seam is an architecture finding — tangled callers, hidden coupling, or a module with no testable boundary — and the handoff is the `improve-codebase-architecture` skill, with those specifics attached. diff --git a/plugins/bin/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/skills/improve-codebase-architecture/SKILL.md index 05984a6..22d02fb 100644 --- a/plugins/bin/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/skills/improve-codebase-architecture/SKILL.md @@ -65,7 +65,7 @@ Once the user picks a candidate, drop into a grilling conversation. Walk the des Side effects happen inline as decisions crystallize: -- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. -- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. - **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/plugins/bin/skills/prototype/SKILL.md b/plugins/bin/skills/prototype/SKILL.md index e938808..0c9d8e3 100644 --- a/plugins/bin/skills/prototype/SKILL.md +++ b/plugins/bin/skills/prototype/SKILL.md @@ -3,8 +3,8 @@ name: prototype description: > Use when the user wants a throwaway prototype to answer a design question about a data model, state machine or business logic, or to mock up a UI in several - variations, as in "try a few designs". Not production code -> `tdd`. Not - talking a design through without building -> `grill-me`. + variations. Not production code -> `tdd`. Not talking a design through -> + `grill-me`. --- # Prototype @@ -13,10 +13,12 @@ A prototype is **throwaway code that answers a question**. The question decides ## Pick a branch -Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: +| Question being answered | Build | Reference | +|---|---|---| +| "Does this logic / state model feel right?" | A tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper | `references/logic.md` | +| "What should this look like?" | Several radically different UI variations on one route, switchable via a URL search param and a floating bottom bar | `references/ui.md` | -- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. -- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. +Resolve the row from the user's prompt, the surrounding code, or by asking if the user is around, then read only that reference — each is self-contained. The two branches produce fundamentally different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. diff --git a/plugins/bin/skills/prototype/LOGIC.md b/plugins/bin/skills/prototype/references/logic.md similarity index 94% rename from plugins/bin/skills/prototype/LOGIC.md rename to plugins/bin/skills/prototype/references/logic.md index 526ecb1..2945853 100644 --- a/plugins/bin/skills/prototype/LOGIC.md +++ b/plugins/bin/skills/prototype/references/logic.md @@ -9,7 +9,7 @@ A tiny interactive terminal app that lets the user drive a state model by hand. - "I want to feel out what the API should look like before writing it." - Anything where the user wants to **press buttons and watch state change**. -If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). +If the question is "what should this look like" — wrong branch. Read `references/ui.md`. ## Process @@ -72,8 +72,5 @@ When the prototype has done its job, the answer to the question is the only thin ## Anti-patterns -- **Don't add tests.** A prototype that needs tests is no longer a prototype. -- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. -- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. - **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. - **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/plugins/bin/skills/prototype/UI.md b/plugins/bin/skills/prototype/references/ui.md similarity index 96% rename from plugins/bin/skills/prototype/UI.md rename to plugins/bin/skills/prototype/references/ui.md index f3b6e64..4de17de 100644 --- a/plugins/bin/skills/prototype/UI.md +++ b/plugins/bin/skills/prototype/references/ui.md @@ -2,7 +2,7 @@ Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. -If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). +If the question is about logic/state rather than what something looks like — wrong branch. Read `references/logic.md`. ## When this is the right shape @@ -109,4 +109,3 @@ Don't leave variant components or the switcher lying around. They rot fast and c - **Variants that differ only in colour or copy.** That's a tweak, not a prototype. Real variants disagree about structure. - **Sharing too much code between variants.** A shared `<Header>` is fine; a shared `<Layout>` defeats the point. Each variant should be free to throw out the layout. - **Wiring variants to real mutations.** Read-only prototypes are fine. If a variant needs to mutate, point it at a stub — the question is "what should this look like", not "does the backend work". -- **Promoting the prototype directly to production.** The variant code was written under prototype constraints (no tests, minimal error handling). Rewrite it properly when you fold it in. diff --git a/plugins/bin/skills/research/SKILL.md b/plugins/bin/skills/research/SKILL.md index 2ab6459..1779011 100644 --- a/plugins/bin/skills/research/SKILL.md +++ b/plugins/bin/skills/research/SKILL.md @@ -8,10 +8,12 @@ description: >- metadata: category: research allowed-tools: - - WebSearch - - WebFetch + - Grep + - Glob - Read - Write + - WebSearch + - WebFetch - mcp__context7__resolve-library-id - mcp__context7__query-docs model: sonnet @@ -61,4 +63,4 @@ Merge every set of notes, Context7 and web alike, by topic area. Read `reference Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. -If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. A directory of empty files is worse than an honest miss. +If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/skills/triage/AGENT-BRIEF.md b/plugins/bin/skills/triage/AGENT-BRIEF.md index 2efecdf..a7e1aa8 100644 --- a/plugins/bin/skills/triage/AGENT-BRIEF.md +++ b/plugins/bin/skills/triage/AGENT-BRIEF.md @@ -1,6 +1,6 @@ # Writing Agent Briefs -An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. +An agent brief is a structured comment posted on an issue in the issue tracker when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract. ## Principles @@ -27,7 +27,7 @@ Describe **what** the system should do, not **how** to implement it. The agent w The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable. -- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification" +- **Good:** "Querying the issue tracker for the `needs-triage` label returns issues that have been through initial classification" - **Bad:** "Triage should work correctly" ### Explicit scope boundaries diff --git a/plugins/bin/skills/triage/SKILL.md b/plugins/bin/skills/triage/SKILL.md index 3dee68f..0254fae 100644 --- a/plugins/bin/skills/triage/SKILL.md +++ b/plugins/bin/skills/triage/SKILL.md @@ -35,7 +35,7 @@ Five **state** roles: Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else. -These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not. +These are canonical role names — the actual label strings used in the issue tracker may differ. Resolve each canonical name against the tracker's live label set before applying it, using whichever tracker skill this install provides. If a name has no counterpart there, report the gap and ask the maintainer for the mapping — never substitute a guess. State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding. diff --git a/plugins/bin/skills/write-docs/SKILL.md b/plugins/bin/skills/write-docs/SKILL.md index 0d2da2d..85289e9 100644 --- a/plugins/bin/skills/write-docs/SKILL.md +++ b/plugins/bin/skills/write-docs/SKILL.md @@ -35,7 +35,7 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `to-prd` or `grill-me` +- User wants a PRD, decision doc, or architecture proposal → `grill-me` or `grill-with-docs` - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -88,7 +88,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `to-prd`, `grill-me`, or `grill-with-docs` +- Requested output is a PRD, decision doc, or architecture proposal → redirect to `grill-me` or `grill-with-docs` ## Self-check -- 2.43.0 From e869912374dfa40ce95b31325c511198533df580 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:09 +0000 Subject: [PATCH 66/89] docs(bin): give every bin skill a README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bin skills were the only plugin without per-skill READMEs, so a reader had to open SKILL.md — an agent-facing contract, not an explainer — to learn what a skill does and when it fires. Each README states purpose, triggers and boundaries for a human audience, leaving SKILL.md free to stay terse. --- plugins/bin/.apm/skills/caveman/README.md | 29 +++++++++++++++ plugins/bin/.apm/skills/diagnose/README.md | 35 ++++++++++++++++++ plugins/bin/.apm/skills/grill-me/README.md | 27 ++++++++++++++ .../bin/.apm/skills/grill-with-docs/README.md | 36 +++++++++++++++++++ .../improve-codebase-architecture/README.md | 36 +++++++++++++++++++ plugins/bin/.apm/skills/prototype/README.md | 32 +++++++++++++++++ plugins/bin/.apm/skills/research/README.md | 31 ++++++++++++++++ plugins/bin/.apm/skills/tdd/README.md | 32 +++++++++++++++++ plugins/bin/.apm/skills/triage/README.md | 35 ++++++++++++++++++ plugins/bin/.apm/skills/write-docs/README.md | 30 ++++++++++++++++ plugins/bin/.apm/skills/zoom-out/README.md | 25 +++++++++++++ plugins/bin/skills/caveman/README.md | 29 +++++++++++++++ plugins/bin/skills/diagnose/README.md | 35 ++++++++++++++++++ plugins/bin/skills/grill-me/README.md | 27 ++++++++++++++ plugins/bin/skills/grill-with-docs/README.md | 36 +++++++++++++++++++ .../improve-codebase-architecture/README.md | 36 +++++++++++++++++++ plugins/bin/skills/prototype/README.md | 32 +++++++++++++++++ plugins/bin/skills/research/README.md | 31 ++++++++++++++++ plugins/bin/skills/tdd/README.md | 32 +++++++++++++++++ plugins/bin/skills/triage/README.md | 35 ++++++++++++++++++ plugins/bin/skills/write-docs/README.md | 30 ++++++++++++++++ plugins/bin/skills/zoom-out/README.md | 25 +++++++++++++ 22 files changed, 696 insertions(+) create mode 100644 plugins/bin/.apm/skills/caveman/README.md create mode 100644 plugins/bin/.apm/skills/diagnose/README.md create mode 100644 plugins/bin/.apm/skills/grill-me/README.md create mode 100644 plugins/bin/.apm/skills/grill-with-docs/README.md create mode 100644 plugins/bin/.apm/skills/improve-codebase-architecture/README.md create mode 100644 plugins/bin/.apm/skills/prototype/README.md create mode 100644 plugins/bin/.apm/skills/research/README.md create mode 100644 plugins/bin/.apm/skills/tdd/README.md create mode 100644 plugins/bin/.apm/skills/triage/README.md create mode 100644 plugins/bin/.apm/skills/write-docs/README.md create mode 100644 plugins/bin/.apm/skills/zoom-out/README.md create mode 100644 plugins/bin/skills/caveman/README.md create mode 100644 plugins/bin/skills/diagnose/README.md create mode 100644 plugins/bin/skills/grill-me/README.md create mode 100644 plugins/bin/skills/grill-with-docs/README.md create mode 100644 plugins/bin/skills/improve-codebase-architecture/README.md create mode 100644 plugins/bin/skills/prototype/README.md create mode 100644 plugins/bin/skills/research/README.md create mode 100644 plugins/bin/skills/tdd/README.md create mode 100644 plugins/bin/skills/triage/README.md create mode 100644 plugins/bin/skills/write-docs/README.md create mode 100644 plugins/bin/skills/zoom-out/README.md diff --git a/plugins/bin/.apm/skills/caveman/README.md b/plugins/bin/.apm/skills/caveman/README.md new file mode 100644 index 0000000..63ba67c --- /dev/null +++ b/plugins/bin/.apm/skills/caveman/README.md @@ -0,0 +1,29 @@ +# caveman + +Ultra-compressed output mode: drop articles, filler and pleasantries, keep the technical substance exact. + +## What it does + +Switches the agent into a terse register — no articles, no hedging, no pleasantries, fragments allowed, arrows for causality — while leaving technical terms, code blocks and quoted error strings untouched. The mode is *sticky*: once turned on it stays on for every subsequent response until the user says "stop caveman" or "normal mode", rather than decaying back to normal prose after a few turns. + +It carries one built-in escape hatch. Security warnings, confirmations for irreversible actions, multi-step sequences where fragment order could be misread, and any request to clarify are answered in normal prose, then the compressed register resumes. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`. This is the single most important thing to know about this skill: **the model cannot route to it.** No other skill can hand off to it, and no phrasing in a user's request will cause it to be selected automatically. The only way in is the human typing `/caveman`. + +That is deliberate — output style is the user's choice, not an inference the router should make on their behalf. It is also why the description reads as one plain human-facing sentence rather than carrying the trigger phrasing and boundary clause a routable skill needs. + +## Usage + +```text +/caveman +``` + +Then keep working normally. To leave the mode, say "stop caveman" or "normal mode". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — persistence rule, compression rules, worked examples, and the auto-clarity exception | diff --git a/plugins/bin/.apm/skills/diagnose/README.md b/plugins/bin/.apm/skills/diagnose/README.md new file mode 100644 index 0000000..6e2c414 --- /dev/null +++ b/plugins/bin/.apm/skills/diagnose/README.md @@ -0,0 +1,35 @@ +# diagnose + +A six-phase discipline for hard bugs and performance regressions: feedback loop → reproduce → hypothesise → instrument → fix with a regression test → clean up. + +## What it does + +Imposes an order of operations on debugging so the agent cannot skip to guessing. The load-bearing phase is the first one: build a fast, deterministic, agent-runnable pass/fail signal for the bug. Everything downstream — bisection, hypothesis testing, instrumentation — just consumes that signal, so the skill refuses to advance to Phase 2 without one, and says so explicitly rather than hypothesising blind. + +The remaining phases each carry a constraint worth knowing about: hypotheses are generated 3–5 at a time and must be falsifiable, so the first plausible idea cannot anchor the whole investigation; every debug log is tagged with a unique prefix (`[DEBUG-a4f2]`) so cleanup is a single grep; the regression test is written before the fix and only at a seam that exercises the real bug pattern; and the run closes by asking what would have prevented the bug, handing off to `improve-codebase-architecture` when the answer is architectural. + +Performance regressions take a branch of their own inside Phase 4 — baseline measurement and bisection, not logs. + +## Conditional reading + +Neither reference file is read on every run; `SKILL.md` names the condition for each. + +- `references/feedback-loops.md` is read when Phase 1 has no signal yet, or when the loop you have is slow or intermittent. +- `references/regression-seams.md` is read when Phase 5 leaves you unsure whether the available seam is deep enough — or whether one exists at all. + +## Usage + +```text +/diagnose +``` + +Describe the bug or the regression. For filing and triaging a reported bug rather than diagnosing it, use `triage`; for test-first feature work, use `tdd`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The six phases and their gates — what must be true before each one ends | +| `references/feedback-loops.md` | Loaded when Phase 1 has no loop or the loop is too weak: ten ways to construct one ordered by cost, how to sharpen an existing loop, handling intermittent bugs, and what to ask the user for when the bug resists reproduction | +| `references/regression-seams.md` | Loaded when Phase 5 is unsure about the seam: what makes a seam correct, the four shapes of a too-shallow seam, and what to do when no correct seam exists | +| `assets/hitl-loop.template.sh` | Copy-and-edit bash template for the last-resort human-in-the-loop feedback loop, cited by `references/feedback-loops.md`. Provides `step` and `capture` helpers and prints captured values as `KEY=VALUE` for the agent to parse | diff --git a/plugins/bin/.apm/skills/grill-me/README.md b/plugins/bin/.apm/skills/grill-me/README.md new file mode 100644 index 0000000..6197081 --- /dev/null +++ b/plugins/bin/.apm/skills/grill-me/README.md @@ -0,0 +1,27 @@ +# grill-me + +Interview the user relentlessly about a plan or design until the decision tree is fully resolved. + +## What it does + +Turns the agent into an interviewer rather than an implementer. It walks the design tree branch by branch, resolving dependencies between decisions one at a time, and offers its own recommended answer alongside each question so the user has something concrete to push against. Two rules give it its shape: **one question at a time**, and **never ask what the codebase can answer** — if a question is settleable by reading the code, the agent goes and reads the code instead of spending the user's attention on it. + +## Composition + +This is the plain grilling loop, with no documentation side effects. The sibling `grill-with-docs` skill runs the same interview but additionally challenges answers against the project's `CONTEXT.md` glossary and existing ADRs, and writes decisions back into those files as they crystallise. Reach for that one when the project has a domain model worth defending; reach for this one when it does not, or when nothing should be written down yet. + +`triage` composes the documented variant, not this one, when an issue needs fleshing out. + +## Usage + +```text +/grill-me +``` + +Describe the plan or design to be stress-tested. Expect questions one at a time, each with a recommended answer. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — the interview instruction, the one-question-at-a-time rule, and the explore-instead-of-asking rule | diff --git a/plugins/bin/.apm/skills/grill-with-docs/README.md b/plugins/bin/.apm/skills/grill-with-docs/README.md new file mode 100644 index 0000000..11b544b --- /dev/null +++ b/plugins/bin/.apm/skills/grill-with-docs/README.md @@ -0,0 +1,36 @@ +# grill-with-docs + +The grilling interview, run against the project's domain model — and writing decisions back into `CONTEXT.md` and ADRs as they land. + +## What it does + +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it four ways: + +- **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. +- **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. +- **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. + +Files are created lazily — only when there is something real to write. + +ADRs are offered *sparingly*, and only when all three tests pass: the decision is hard to reverse, it would surprise a future reader without the context, and it was a genuine trade-off with real alternatives. Missing any one of the three means no ADR. + +## Composition + +`grill-me` is the same interview without the documentation side effects — use it when there is no domain model to defend or nothing should be written down yet. `triage` composes this skill (not `grill-me`) at step 4 when an issue needs fleshing out. `improve-codebase-architecture` runs its own grilling loop and borrows this skill's `CONTEXT.md` and ADR discipline for the decisions that come out of it. + +## Usage + +```text +/grill-with-docs +``` + +Describe the plan or design. Expect questions one at a time, each with a recommended answer, and expect `CONTEXT.md` to be edited during the session rather than after it. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the four during-session behaviours, and the three-part ADR test | +| `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | +| `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/README.md b/plugins/bin/.apm/skills/improve-codebase-architecture/README.md new file mode 100644 index 0000000..509cad7 --- /dev/null +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/README.md @@ -0,0 +1,36 @@ +# improve-codebase-architecture + +Surface architectural friction and propose deepening opportunities — refactors that turn shallow modules into deep ones. + +## What it does + +Looks for places where a codebase is hard to understand, hard to test, or hard for an agent to navigate, and proposes refactors that concentrate behaviour behind smaller interfaces. It runs in three stages: + +1. **Explore.** Reads the domain glossary and any ADRs in the area first, then walks the codebase with an `Explore` sub-agent — organically, noting friction rather than applying fixed heuristics. The **deletion test** is the filter: imagine deleting the module; if complexity vanishes it was a pass-through, if complexity reappears across N callers it was earning its keep. +2. **Present candidates.** A numbered list, each with files, problem, solution and benefits — benefits stated in terms of *locality* and *leverage* and of how tests would improve. No interfaces are proposed yet; the user picks one. +3. **Grilling loop.** Walks the design tree for the chosen candidate, with documentation side effects landing inline as decisions crystallise. + +The skill is opinionated about vocabulary, and that is the point: **module, interface, implementation, depth, seam, adapter, leverage, locality**, used exactly, with no drift into "component", "service", "API" or "boundary". Domain nouns come from `CONTEXT.md`, architecture nouns from `LANGUAGE.md` — so a proposal reads as "the Order intake module", never "the FooBarHandler". + +ADRs are treated as decisions not to be re-litigated. A candidate that contradicts one is surfaced only when the friction is real enough to warrant reopening it, and is marked as such. + +## Composition + +`diagnose` hands off here when a bug's post-mortem concludes that no correct test seam exists, or that callers are tangled — the recommendation is made after the fix is in, not before. The grilling loop follows `grill-with-docs`'s discipline for `CONTEXT.md` entries and ADR offers, and `SKILL.md` names that skill's format documents directly. + +## Usage + +```text +/improve-codebase-architecture +``` + +Point at a codebase or an area of one. Expect a numbered candidate list and a "which of these would you like to explore?" before any interface design happens. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Condensed glossary, key principles, and the three-stage process | +| `LANGUAGE.md` | Skill-root document, cited throughout `SKILL.md`: full definitions of every term, the words each one replaces, and the full principle list | +| `INTERFACE-DESIGN.md` | Skill-root document, read at stage 3 when the user wants alternative interfaces explored: the parallel sub-agent "Design It Twice" pattern, framing the problem space, and the per-agent design constraints | +| `DEEPENING.md` | Skill-root document, cited from `INTERFACE-DESIGN.md`: how to deepen a cluster of shallow modules safely, the four dependency categories (in-process, local-substitutable, remote-but-owned, true external), seam discipline, and the replace-don't-layer testing strategy | diff --git a/plugins/bin/.apm/skills/prototype/README.md b/plugins/bin/.apm/skills/prototype/README.md new file mode 100644 index 0000000..425a6e8 --- /dev/null +++ b/plugins/bin/.apm/skills/prototype/README.md @@ -0,0 +1,32 @@ +# prototype + +Build a throwaway prototype that answers one design question — either a runnable terminal app or several UI variations. + +## What it does + +Treats a prototype as **throwaway code that answers a question**, and lets the question decide the artifact. `SKILL.md` opens with a two-row dispatch table and the run resolves exactly one row before doing anything else: + +- *"Does this logic / state model feel right?"* → a tiny interactive terminal app that pushes the state machine through the cases that are hard to reason about on paper. +- *"What should this look like?"* → several radically different UI variations on one route, switchable from a floating bottom bar via a URL search param. + +The two branches produce fundamentally different artifacts, so picking wrong wastes the whole prototype. When the question is genuinely ambiguous and the user is unreachable, the skill defaults on the shape of the surrounding code (backend module → logic, page or component → UI) and states the assumption at the top of the prototype rather than silently choosing. + +Six rules apply to both branches: throwaway and visibly named as such, one command to run, no persistence by default, no polish, surface the full state after every action or variant switch, and delete or absorb the prototype when it is done. The *answer* is the only durable output — the skill captures it in a commit message, ADR, issue or `NOTES.md` before the code is deleted. + +## Usage + +```text +/prototype +``` + +State the design question. For production code, use `tdd`; for talking a design through without building anything, use `grill-me`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The branch dispatch table and the rules that apply to both branches | +| `references/logic.md` | The logic branch, read only when that row is selected: when it is the right shape, and how to build the interactive terminal app | +| `references/ui.md` | The UI branch, read only when that row is selected: when it is the right shape, and how to build and switch between the variations | + +Each reference is self-contained — a run reads one of the two, never both. diff --git a/plugins/bin/.apm/skills/research/README.md b/plugins/bin/.apm/skills/research/README.md new file mode 100644 index 0000000..b98c00b --- /dev/null +++ b/plugins/bin/.apm/skills/research/README.md @@ -0,0 +1,31 @@ +# research + +Research a tool, library or API from canonical documentation into a directory of structured per-topic reference files. + +## What it does + +Runs a six-step pipeline: scope against the working directory (what version is actually in use, what is already documented), resolve the topic through Context7, websearch for canonical docs covering whatever Context7 missed, read those sources, deepen one level into the links worth following, then write one markdown file per topic area plus a `sources.md` provenance record. + +Four gotchas at the top of `SKILL.md` shape the whole run, and each exists because of a specific failure: the output path is never inferred (a guessed destination scatters a directory's worth of files through someone's source tree); nothing is written outside that path; no empty topic file is ever written (a stub `troubleshooting.md` reads downstream as researched and closed); and a Context7 "no results", redirect or header-only response does not count as coverage. If no topic area has content, the run writes nothing at all — `sources.md` included — and reports what it searched. + +The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably it grants no subagent tool, so every `WebFetch` is serial and each fetched page lands in the run's own context — which is why steps 4 and 5 insist on reducing each page to notes before fetching the next, and cap deepening at roughly ten extra pages. + +## Composition + +`references/file-format.md` is not optional reading before the write step: the `sources.md` field names it defines are matched literally by the downstream provenance validator. Prose written in their place parses as nothing and the check passes having verified nothing. + +## Usage + +```text +/research +``` + +Name the topic and the output path — the skill will stop and ask if the path is missing. Supplying starting URLs is treated as a deliberate source choice and skips Context7 resolution and discovery. For documentation derived from existing code or specs, use `write-docs`; for a bug or incident, use `diagnose`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The four gotchas and the six research steps | +| `references/topics.md` | Read at Step 1 before narrowing scope: the default topic list (`overview`, `installation`, `configuration`, `cli-reference`, `api-reference`, `examples`, and more) and what each file covers | +| `references/file-format.md` | Read at Step 6 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | diff --git a/plugins/bin/.apm/skills/tdd/README.md b/plugins/bin/.apm/skills/tdd/README.md new file mode 100644 index 0000000..d3b62e6 --- /dev/null +++ b/plugins/bin/.apm/skills/tdd/README.md @@ -0,0 +1,32 @@ +# tdd + +Test-driven development as a strict red-green-refactor loop, one behaviour at a time. + +## What it does + +Two convictions drive this skill. The first is about what a test is for: tests verify behaviour through public interfaces, not implementation details. A good test reads like a specification ("user can checkout with valid cart") and survives refactors because it does not care about internal structure. The warning sign for a bad one is precise — the test breaks when you refactor but behaviour has not changed. + +The second is an explicit anti-pattern: **do not write all the tests first, then all the implementation.** Horizontal slicing treats RED as "write every test" and GREEN as "write every implementation", and it produces tests of *imagined* behaviour — tests of the shape of things, insensitive to real change, committed to before the implementation was understood. The correct shape is vertical: one test → one implementation → repeat, each cycle informed by what the last one taught you. + +The workflow is four stages: plan (confirm the interface and which behaviours matter, with the user — you cannot test everything), fire a tracer bullet (one test proving the path works end to end), loop incrementally one behaviour at a time, then refactor once everything is green. Refactoring while RED is forbidden. + +Codebase exploration uses the project's domain glossary, so test names and interface vocabulary match the project's language, and ADRs in the area are respected. + +## Usage + +```text +/tdd +``` + +Describe the feature or bug. Expect the skill to ask what the public interface should look like and which behaviours matter most before any code is written. For diagnosing an existing bug rather than building test-first, use `diagnose`; for throwaway exploratory code, use `prototype`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Philosophy, the horizontal-slicing anti-pattern, the four-stage workflow, and the per-cycle checklist | +| `tests.md` | Skill-root document, cited from Philosophy: worked good and bad test examples | +| `mocking.md` | Skill-root document, cited from Philosophy: mock at system boundaries only, and what not to mock | +| `deep-modules.md` | Skill-root document, cited from stage 1: what a deep module is (small interface, large implementation) and why it is the design to aim for | +| `interface-design.md` | Skill-root document, cited from stage 1: designing interfaces for testability, starting with accepting dependencies rather than creating them | +| `refactoring.md` | Skill-root document, cited from stage 4: the refactor-candidate checklist — duplication, long methods, shallow modules, feature envy, primitive obsession | diff --git a/plugins/bin/.apm/skills/triage/README.md b/plugins/bin/.apm/skills/triage/README.md new file mode 100644 index 0000000..3f21e51 --- /dev/null +++ b/plugins/bin/.apm/skills/triage/README.md @@ -0,0 +1,35 @@ +# triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +## What it does + +Gives issue triage an explicit state model and a fixed set of moves. Every issue carries exactly one **category** role (`bug`, `enhancement`) and one **state** role (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); conflicting state roles are flagged to the maintainer before anything else happens. Unlabeled issues normally enter at `needs-triage`; `needs-info` returns there once the reporter replies. The maintainer can override at any point, and unusual transitions are questioned rather than executed silently. + +A run does one of three things depending on what the maintainer asks for: + +- **Show what needs attention** — three buckets, oldest first: unlabeled, `needs-triage`, and `needs-info` with reporter activity since the last triage notes. +- **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. +- **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. + +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — the mapping has to be supplied to the run. + +## Composition + +`grill-with-docs` is invoked at step 4 when an issue needs fleshing out; whatever that session establishes is carried into the triage notes so the work is not lost. The reverse direction also exists: `diagnose` names this skill as the place to send a *reported* bug that needs filing rather than debugging. + +## Usage + +```text +/triage +``` + +Then describe what you want in natural language — "show me anything that needs my attention", "let's look at #42", "move #42 to ready-for-agent", "what's ready for agents to pick up?". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The roles and state machine, the three invocation modes, the needs-info template, and how to resume a prior session | +| `AGENT-BRIEF.md` | Skill-root document, cited when an issue moves to `ready-for-agent` (and reused for `ready-for-human`): how to write a brief that stays durable for weeks while the codebase moves under it — describe interfaces and behavioural contracts, not line numbers | +| `OUT-OF-SCOPE.md` | Skill-root document, cited when an enhancement is closed `wontfix` and when checking for prior rejections: how the `.out-of-scope/` knowledge base is laid out and what it is for — institutional memory, and deduplication against re-litigated requests | diff --git a/plugins/bin/.apm/skills/write-docs/README.md b/plugins/bin/.apm/skills/write-docs/README.md new file mode 100644 index 0000000..8ed0156 --- /dev/null +++ b/plugins/bin/.apm/skills/write-docs/README.md @@ -0,0 +1,30 @@ +# write-docs + +Produce technical documentation derived from code and spec, one section at a time, with a confirmation gate on every section. + +## What it does + +Casts the agent as a technical writer with one non-negotiable constraint: **every claim must be traceable to a source file line, a spec section, or an explicit user statement.** Nothing is invented, and behaviour that genuinely cannot be documented from the available sources is marked out-of-scope rather than explained away. + +The process is eight steps — identify scope, read and extract, gap check, draft section by section, confirmation gate, delta summary, reader testing, finalise — and several of them are deliberately gated on the human: + +- Files are read only after the user approves them by name. The skill may propose candidates; it waits. +- The **gap check** presents what the code does say and asks the user to fill only what it does not: caller intent, error-handling rationale, non-obvious side effects. +- No section is finalised until the full revised text has been shown. The skill never gates on output the user has not seen, and never reprints the whole document — all edits are surgical. +- **Reader testing** predicts 5–10 questions a target reader would ask, then spawns a sub-agent that receives only the finished doc and the questions — no source files. If the doc cannot answer them, neither can the sub-agent, and the run loops back to drafting. + +Summary and overview sections are written last, once the detail sections are stable. + +## Usage + +```text +/write-docs +``` + +Name the files or modules to document, the target audience (developer / user / contributor / internal), and the documentation type (reference, guide, README section, inline comment, changelog entry). For a PRD, ADR or decision doc, use `grill-me` or `grill-with-docs` instead — those have dedicated handling. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — role, use/do-not-use boundaries, required inputs, constraints, the eight-step process, output format, failure handling, and a nine-item self-check | diff --git a/plugins/bin/.apm/skills/zoom-out/README.md b/plugins/bin/.apm/skills/zoom-out/README.md new file mode 100644 index 0000000..ce71a99 --- /dev/null +++ b/plugins/bin/.apm/skills/zoom-out/README.md @@ -0,0 +1,25 @@ +# zoom-out + +Ask the agent to go up a layer of abstraction and map the modules and callers around unfamiliar code. + +## What it does + +A single-purpose prompt for the moment you land in a part of the codebase you do not know. Instead of answering at the level of the file in front of it, the agent climbs one layer and produces a map of the relevant modules and their callers — and names them using the project's own domain glossary vocabulary, so the map lines up with the language the rest of the repo already uses. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`, so the router never selects this skill on its own and no other skill can hand off to it. It runs when the human asks for it. That also means its description is written as one plain human-facing sentence — it carries no trigger phrasing or boundary clause, because nothing routes on it. + +## Usage + +```text +/zoom-out +``` + +Best used with the unfamiliar code already in context — the skill widens the view around what you are looking at rather than picking a starting point for you. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — a single instruction, no supporting files | diff --git a/plugins/bin/skills/caveman/README.md b/plugins/bin/skills/caveman/README.md new file mode 100644 index 0000000..63ba67c --- /dev/null +++ b/plugins/bin/skills/caveman/README.md @@ -0,0 +1,29 @@ +# caveman + +Ultra-compressed output mode: drop articles, filler and pleasantries, keep the technical substance exact. + +## What it does + +Switches the agent into a terse register — no articles, no hedging, no pleasantries, fragments allowed, arrows for causality — while leaving technical terms, code blocks and quoted error strings untouched. The mode is *sticky*: once turned on it stays on for every subsequent response until the user says "stop caveman" or "normal mode", rather than decaying back to normal prose after a few turns. + +It carries one built-in escape hatch. Security warnings, confirmations for irreversible actions, multi-step sequences where fragment order could be misread, and any request to clarify are answered in normal prose, then the compressed register resumes. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`. This is the single most important thing to know about this skill: **the model cannot route to it.** No other skill can hand off to it, and no phrasing in a user's request will cause it to be selected automatically. The only way in is the human typing `/caveman`. + +That is deliberate — output style is the user's choice, not an inference the router should make on their behalf. It is also why the description reads as one plain human-facing sentence rather than carrying the trigger phrasing and boundary clause a routable skill needs. + +## Usage + +```text +/caveman +``` + +Then keep working normally. To leave the mode, say "stop caveman" or "normal mode". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — persistence rule, compression rules, worked examples, and the auto-clarity exception | diff --git a/plugins/bin/skills/diagnose/README.md b/plugins/bin/skills/diagnose/README.md new file mode 100644 index 0000000..6e2c414 --- /dev/null +++ b/plugins/bin/skills/diagnose/README.md @@ -0,0 +1,35 @@ +# diagnose + +A six-phase discipline for hard bugs and performance regressions: feedback loop → reproduce → hypothesise → instrument → fix with a regression test → clean up. + +## What it does + +Imposes an order of operations on debugging so the agent cannot skip to guessing. The load-bearing phase is the first one: build a fast, deterministic, agent-runnable pass/fail signal for the bug. Everything downstream — bisection, hypothesis testing, instrumentation — just consumes that signal, so the skill refuses to advance to Phase 2 without one, and says so explicitly rather than hypothesising blind. + +The remaining phases each carry a constraint worth knowing about: hypotheses are generated 3–5 at a time and must be falsifiable, so the first plausible idea cannot anchor the whole investigation; every debug log is tagged with a unique prefix (`[DEBUG-a4f2]`) so cleanup is a single grep; the regression test is written before the fix and only at a seam that exercises the real bug pattern; and the run closes by asking what would have prevented the bug, handing off to `improve-codebase-architecture` when the answer is architectural. + +Performance regressions take a branch of their own inside Phase 4 — baseline measurement and bisection, not logs. + +## Conditional reading + +Neither reference file is read on every run; `SKILL.md` names the condition for each. + +- `references/feedback-loops.md` is read when Phase 1 has no signal yet, or when the loop you have is slow or intermittent. +- `references/regression-seams.md` is read when Phase 5 leaves you unsure whether the available seam is deep enough — or whether one exists at all. + +## Usage + +```text +/diagnose +``` + +Describe the bug or the regression. For filing and triaging a reported bug rather than diagnosing it, use `triage`; for test-first feature work, use `tdd`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The six phases and their gates — what must be true before each one ends | +| `references/feedback-loops.md` | Loaded when Phase 1 has no loop or the loop is too weak: ten ways to construct one ordered by cost, how to sharpen an existing loop, handling intermittent bugs, and what to ask the user for when the bug resists reproduction | +| `references/regression-seams.md` | Loaded when Phase 5 is unsure about the seam: what makes a seam correct, the four shapes of a too-shallow seam, and what to do when no correct seam exists | +| `assets/hitl-loop.template.sh` | Copy-and-edit bash template for the last-resort human-in-the-loop feedback loop, cited by `references/feedback-loops.md`. Provides `step` and `capture` helpers and prints captured values as `KEY=VALUE` for the agent to parse | diff --git a/plugins/bin/skills/grill-me/README.md b/plugins/bin/skills/grill-me/README.md new file mode 100644 index 0000000..6197081 --- /dev/null +++ b/plugins/bin/skills/grill-me/README.md @@ -0,0 +1,27 @@ +# grill-me + +Interview the user relentlessly about a plan or design until the decision tree is fully resolved. + +## What it does + +Turns the agent into an interviewer rather than an implementer. It walks the design tree branch by branch, resolving dependencies between decisions one at a time, and offers its own recommended answer alongside each question so the user has something concrete to push against. Two rules give it its shape: **one question at a time**, and **never ask what the codebase can answer** — if a question is settleable by reading the code, the agent goes and reads the code instead of spending the user's attention on it. + +## Composition + +This is the plain grilling loop, with no documentation side effects. The sibling `grill-with-docs` skill runs the same interview but additionally challenges answers against the project's `CONTEXT.md` glossary and existing ADRs, and writes decisions back into those files as they crystallise. Reach for that one when the project has a domain model worth defending; reach for this one when it does not, or when nothing should be written down yet. + +`triage` composes the documented variant, not this one, when an issue needs fleshing out. + +## Usage + +```text +/grill-me +``` + +Describe the plan or design to be stress-tested. Expect questions one at a time, each with a recommended answer. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — the interview instruction, the one-question-at-a-time rule, and the explore-instead-of-asking rule | diff --git a/plugins/bin/skills/grill-with-docs/README.md b/plugins/bin/skills/grill-with-docs/README.md new file mode 100644 index 0000000..11b544b --- /dev/null +++ b/plugins/bin/skills/grill-with-docs/README.md @@ -0,0 +1,36 @@ +# grill-with-docs + +The grilling interview, run against the project's domain model — and writing decisions back into `CONTEXT.md` and ADRs as they land. + +## What it does + +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it four ways: + +- **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. +- **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. +- **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. + +Files are created lazily — only when there is something real to write. + +ADRs are offered *sparingly*, and only when all three tests pass: the decision is hard to reverse, it would surprise a future reader without the context, and it was a genuine trade-off with real alternatives. Missing any one of the three means no ADR. + +## Composition + +`grill-me` is the same interview without the documentation side effects — use it when there is no domain model to defend or nothing should be written down yet. `triage` composes this skill (not `grill-me`) at step 4 when an issue needs fleshing out. `improve-codebase-architecture` runs its own grilling loop and borrows this skill's `CONTEXT.md` and ADR discipline for the decisions that come out of it. + +## Usage + +```text +/grill-with-docs +``` + +Describe the plan or design. Expect questions one at a time, each with a recommended answer, and expect `CONTEXT.md` to be edited during the session rather than after it. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the four during-session behaviours, and the three-part ADR test | +| `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | +| `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/skills/improve-codebase-architecture/README.md b/plugins/bin/skills/improve-codebase-architecture/README.md new file mode 100644 index 0000000..509cad7 --- /dev/null +++ b/plugins/bin/skills/improve-codebase-architecture/README.md @@ -0,0 +1,36 @@ +# improve-codebase-architecture + +Surface architectural friction and propose deepening opportunities — refactors that turn shallow modules into deep ones. + +## What it does + +Looks for places where a codebase is hard to understand, hard to test, or hard for an agent to navigate, and proposes refactors that concentrate behaviour behind smaller interfaces. It runs in three stages: + +1. **Explore.** Reads the domain glossary and any ADRs in the area first, then walks the codebase with an `Explore` sub-agent — organically, noting friction rather than applying fixed heuristics. The **deletion test** is the filter: imagine deleting the module; if complexity vanishes it was a pass-through, if complexity reappears across N callers it was earning its keep. +2. **Present candidates.** A numbered list, each with files, problem, solution and benefits — benefits stated in terms of *locality* and *leverage* and of how tests would improve. No interfaces are proposed yet; the user picks one. +3. **Grilling loop.** Walks the design tree for the chosen candidate, with documentation side effects landing inline as decisions crystallise. + +The skill is opinionated about vocabulary, and that is the point: **module, interface, implementation, depth, seam, adapter, leverage, locality**, used exactly, with no drift into "component", "service", "API" or "boundary". Domain nouns come from `CONTEXT.md`, architecture nouns from `LANGUAGE.md` — so a proposal reads as "the Order intake module", never "the FooBarHandler". + +ADRs are treated as decisions not to be re-litigated. A candidate that contradicts one is surfaced only when the friction is real enough to warrant reopening it, and is marked as such. + +## Composition + +`diagnose` hands off here when a bug's post-mortem concludes that no correct test seam exists, or that callers are tangled — the recommendation is made after the fix is in, not before. The grilling loop follows `grill-with-docs`'s discipline for `CONTEXT.md` entries and ADR offers, and `SKILL.md` names that skill's format documents directly. + +## Usage + +```text +/improve-codebase-architecture +``` + +Point at a codebase or an area of one. Expect a numbered candidate list and a "which of these would you like to explore?" before any interface design happens. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Condensed glossary, key principles, and the three-stage process | +| `LANGUAGE.md` | Skill-root document, cited throughout `SKILL.md`: full definitions of every term, the words each one replaces, and the full principle list | +| `INTERFACE-DESIGN.md` | Skill-root document, read at stage 3 when the user wants alternative interfaces explored: the parallel sub-agent "Design It Twice" pattern, framing the problem space, and the per-agent design constraints | +| `DEEPENING.md` | Skill-root document, cited from `INTERFACE-DESIGN.md`: how to deepen a cluster of shallow modules safely, the four dependency categories (in-process, local-substitutable, remote-but-owned, true external), seam discipline, and the replace-don't-layer testing strategy | diff --git a/plugins/bin/skills/prototype/README.md b/plugins/bin/skills/prototype/README.md new file mode 100644 index 0000000..425a6e8 --- /dev/null +++ b/plugins/bin/skills/prototype/README.md @@ -0,0 +1,32 @@ +# prototype + +Build a throwaway prototype that answers one design question — either a runnable terminal app or several UI variations. + +## What it does + +Treats a prototype as **throwaway code that answers a question**, and lets the question decide the artifact. `SKILL.md` opens with a two-row dispatch table and the run resolves exactly one row before doing anything else: + +- *"Does this logic / state model feel right?"* → a tiny interactive terminal app that pushes the state machine through the cases that are hard to reason about on paper. +- *"What should this look like?"* → several radically different UI variations on one route, switchable from a floating bottom bar via a URL search param. + +The two branches produce fundamentally different artifacts, so picking wrong wastes the whole prototype. When the question is genuinely ambiguous and the user is unreachable, the skill defaults on the shape of the surrounding code (backend module → logic, page or component → UI) and states the assumption at the top of the prototype rather than silently choosing. + +Six rules apply to both branches: throwaway and visibly named as such, one command to run, no persistence by default, no polish, surface the full state after every action or variant switch, and delete or absorb the prototype when it is done. The *answer* is the only durable output — the skill captures it in a commit message, ADR, issue or `NOTES.md` before the code is deleted. + +## Usage + +```text +/prototype +``` + +State the design question. For production code, use `tdd`; for talking a design through without building anything, use `grill-me`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The branch dispatch table and the rules that apply to both branches | +| `references/logic.md` | The logic branch, read only when that row is selected: when it is the right shape, and how to build the interactive terminal app | +| `references/ui.md` | The UI branch, read only when that row is selected: when it is the right shape, and how to build and switch between the variations | + +Each reference is self-contained — a run reads one of the two, never both. diff --git a/plugins/bin/skills/research/README.md b/plugins/bin/skills/research/README.md new file mode 100644 index 0000000..b98c00b --- /dev/null +++ b/plugins/bin/skills/research/README.md @@ -0,0 +1,31 @@ +# research + +Research a tool, library or API from canonical documentation into a directory of structured per-topic reference files. + +## What it does + +Runs a six-step pipeline: scope against the working directory (what version is actually in use, what is already documented), resolve the topic through Context7, websearch for canonical docs covering whatever Context7 missed, read those sources, deepen one level into the links worth following, then write one markdown file per topic area plus a `sources.md` provenance record. + +Four gotchas at the top of `SKILL.md` shape the whole run, and each exists because of a specific failure: the output path is never inferred (a guessed destination scatters a directory's worth of files through someone's source tree); nothing is written outside that path; no empty topic file is ever written (a stub `troubleshooting.md` reads downstream as researched and closed); and a Context7 "no results", redirect or header-only response does not count as coverage. If no topic area has content, the run writes nothing at all — `sources.md` included — and reports what it searched. + +The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably it grants no subagent tool, so every `WebFetch` is serial and each fetched page lands in the run's own context — which is why steps 4 and 5 insist on reducing each page to notes before fetching the next, and cap deepening at roughly ten extra pages. + +## Composition + +`references/file-format.md` is not optional reading before the write step: the `sources.md` field names it defines are matched literally by the downstream provenance validator. Prose written in their place parses as nothing and the check passes having verified nothing. + +## Usage + +```text +/research +``` + +Name the topic and the output path — the skill will stop and ask if the path is missing. Supplying starting URLs is treated as a deliberate source choice and skips Context7 resolution and discovery. For documentation derived from existing code or specs, use `write-docs`; for a bug or incident, use `diagnose`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The four gotchas and the six research steps | +| `references/topics.md` | Read at Step 1 before narrowing scope: the default topic list (`overview`, `installation`, `configuration`, `cli-reference`, `api-reference`, `examples`, and more) and what each file covers | +| `references/file-format.md` | Read at Step 6 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | diff --git a/plugins/bin/skills/tdd/README.md b/plugins/bin/skills/tdd/README.md new file mode 100644 index 0000000..d3b62e6 --- /dev/null +++ b/plugins/bin/skills/tdd/README.md @@ -0,0 +1,32 @@ +# tdd + +Test-driven development as a strict red-green-refactor loop, one behaviour at a time. + +## What it does + +Two convictions drive this skill. The first is about what a test is for: tests verify behaviour through public interfaces, not implementation details. A good test reads like a specification ("user can checkout with valid cart") and survives refactors because it does not care about internal structure. The warning sign for a bad one is precise — the test breaks when you refactor but behaviour has not changed. + +The second is an explicit anti-pattern: **do not write all the tests first, then all the implementation.** Horizontal slicing treats RED as "write every test" and GREEN as "write every implementation", and it produces tests of *imagined* behaviour — tests of the shape of things, insensitive to real change, committed to before the implementation was understood. The correct shape is vertical: one test → one implementation → repeat, each cycle informed by what the last one taught you. + +The workflow is four stages: plan (confirm the interface and which behaviours matter, with the user — you cannot test everything), fire a tracer bullet (one test proving the path works end to end), loop incrementally one behaviour at a time, then refactor once everything is green. Refactoring while RED is forbidden. + +Codebase exploration uses the project's domain glossary, so test names and interface vocabulary match the project's language, and ADRs in the area are respected. + +## Usage + +```text +/tdd +``` + +Describe the feature or bug. Expect the skill to ask what the public interface should look like and which behaviours matter most before any code is written. For diagnosing an existing bug rather than building test-first, use `diagnose`; for throwaway exploratory code, use `prototype`. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | Philosophy, the horizontal-slicing anti-pattern, the four-stage workflow, and the per-cycle checklist | +| `tests.md` | Skill-root document, cited from Philosophy: worked good and bad test examples | +| `mocking.md` | Skill-root document, cited from Philosophy: mock at system boundaries only, and what not to mock | +| `deep-modules.md` | Skill-root document, cited from stage 1: what a deep module is (small interface, large implementation) and why it is the design to aim for | +| `interface-design.md` | Skill-root document, cited from stage 1: designing interfaces for testability, starting with accepting dependencies rather than creating them | +| `refactoring.md` | Skill-root document, cited from stage 4: the refactor-candidate checklist — duplication, long methods, shallow modules, feature envy, primitive obsession | diff --git a/plugins/bin/skills/triage/README.md b/plugins/bin/skills/triage/README.md new file mode 100644 index 0000000..3f21e51 --- /dev/null +++ b/plugins/bin/skills/triage/README.md @@ -0,0 +1,35 @@ +# triage + +Move issues on the project issue tracker through a small state machine of triage roles. + +## What it does + +Gives issue triage an explicit state model and a fixed set of moves. Every issue carries exactly one **category** role (`bug`, `enhancement`) and one **state** role (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); conflicting state roles are flagged to the maintainer before anything else happens. Unlabeled issues normally enter at `needs-triage`; `needs-info` returns there once the reporter replies. The maintainer can override at any point, and unusual transitions are questioned rather than executed silently. + +A run does one of three things depending on what the maintainer asks for: + +- **Show what needs attention** — three buckets, oldest first: unlabeled, `needs-triage`, and `needs-info` with reporter activity since the last triage notes. +- **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. +- **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. + +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — the mapping has to be supplied to the run. + +## Composition + +`grill-with-docs` is invoked at step 4 when an issue needs fleshing out; whatever that session establishes is carried into the triage notes so the work is not lost. The reverse direction also exists: `diagnose` names this skill as the place to send a *reported* bug that needs filing rather than debugging. + +## Usage + +```text +/triage +``` + +Then describe what you want in natural language — "show me anything that needs my attention", "let's look at #42", "move #42 to ready-for-agent", "what's ready for agents to pick up?". + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The roles and state machine, the three invocation modes, the needs-info template, and how to resume a prior session | +| `AGENT-BRIEF.md` | Skill-root document, cited when an issue moves to `ready-for-agent` (and reused for `ready-for-human`): how to write a brief that stays durable for weeks while the codebase moves under it — describe interfaces and behavioural contracts, not line numbers | +| `OUT-OF-SCOPE.md` | Skill-root document, cited when an enhancement is closed `wontfix` and when checking for prior rejections: how the `.out-of-scope/` knowledge base is laid out and what it is for — institutional memory, and deduplication against re-litigated requests | diff --git a/plugins/bin/skills/write-docs/README.md b/plugins/bin/skills/write-docs/README.md new file mode 100644 index 0000000..8ed0156 --- /dev/null +++ b/plugins/bin/skills/write-docs/README.md @@ -0,0 +1,30 @@ +# write-docs + +Produce technical documentation derived from code and spec, one section at a time, with a confirmation gate on every section. + +## What it does + +Casts the agent as a technical writer with one non-negotiable constraint: **every claim must be traceable to a source file line, a spec section, or an explicit user statement.** Nothing is invented, and behaviour that genuinely cannot be documented from the available sources is marked out-of-scope rather than explained away. + +The process is eight steps — identify scope, read and extract, gap check, draft section by section, confirmation gate, delta summary, reader testing, finalise — and several of them are deliberately gated on the human: + +- Files are read only after the user approves them by name. The skill may propose candidates; it waits. +- The **gap check** presents what the code does say and asks the user to fill only what it does not: caller intent, error-handling rationale, non-obvious side effects. +- No section is finalised until the full revised text has been shown. The skill never gates on output the user has not seen, and never reprints the whole document — all edits are surgical. +- **Reader testing** predicts 5–10 questions a target reader would ask, then spawns a sub-agent that receives only the finished doc and the questions — no source files. If the doc cannot answer them, neither can the sub-agent, and the run loops back to drafting. + +Summary and overview sections are written last, once the detail sections are stable. + +## Usage + +```text +/write-docs +``` + +Name the files or modules to document, the target audience (developer / user / contributor / internal), and the documentation type (reference, guide, README section, inline comment, changelog entry). For a PRD, ADR or decision doc, use `grill-me` or `grill-with-docs` instead — those have dedicated handling. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — role, use/do-not-use boundaries, required inputs, constraints, the eight-step process, output format, failure handling, and a nine-item self-check | diff --git a/plugins/bin/skills/zoom-out/README.md b/plugins/bin/skills/zoom-out/README.md new file mode 100644 index 0000000..ce71a99 --- /dev/null +++ b/plugins/bin/skills/zoom-out/README.md @@ -0,0 +1,25 @@ +# zoom-out + +Ask the agent to go up a layer of abstraction and map the modules and callers around unfamiliar code. + +## What it does + +A single-purpose prompt for the moment you land in a part of the codebase you do not know. Instead of answering at the level of the file in front of it, the agent climbs one layer and produces a map of the relevant modules and their callers — and names them using the project's own domain glossary vocabulary, so the map lines up with the language the rest of the repo already uses. + +## Hand-invoked only + +`SKILL.md` sets `disable-model-invocation: true`, so the router never selects this skill on its own and no other skill can hand off to it. It runs when the human asks for it. That also means its description is written as one plain human-facing sentence — it carries no trigger phrasing or boundary clause, because nothing routes on it. + +## Usage + +```text +/zoom-out +``` + +Best used with the unfamiliar code already in context — the skill widens the view around what you are looking at rather than picking a starting point for you. + +## Files + +| File | Purpose | +|------|---------| +| `SKILL.md` | The whole skill — a single instruction, no supporting files | -- 2.43.0 From dac9cad91291e604ab67fb14df11186473ce75de Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:19 +0000 Subject: [PATCH 67/89] refactor(kyberforge): move audit guidance out of the per-run rubric load skill-audit loaded roughly 4,268 words of rubric on every run, most of it criteria for findings a clean skill never triggers. The auditing guidance moves into finding-criteria.md, read only when a finding is actually raised, cutting a clean audit to about 999 words. The named-skill exemption is replaced with properties, so the rubric stops carrying a list that ages the moment a skill is renamed. apm-workflow's `type:` trap sat in one flow while biting several, so it is promoted to a common gate reachable from all of them; its claim to be self-contained was untrue once it started routing to apm-install. skill-author's contract had drifted from body-discipline.md and is realigned, and agent-audit's field inventory is brought in line with the same split. --- .../.apm/skills/agent-audit/SKILL.md | 4 +- .../agent-audit/references/field-inventory.md | 6 +- .../.apm/skills/apm-workflow/README.md | 4 +- .../.apm/skills/apm-workflow/SKILL.md | 24 ++-- .../skills/apm-workflow/references/compile.md | 2 +- .../apm-workflow/references/configure.md | 2 - .../skills/apm-workflow/references/install.md | 2 +- .../kyberforge/.apm/skills/forge/README.md | 2 +- plugins/kyberforge/.apm/skills/forge/SKILL.md | 17 ++- .../skills/forge/references/version-bump.md | 13 +- .../.apm/skills/skill-audit/README.md | 3 +- .../.apm/skills/skill-audit/SKILL.md | 8 +- .../skill-audit/references/body-discipline.md | 51 +++---- .../references/description-quality.md | 37 +---- .../skill-audit/references/file-structure.md | 41 +++--- .../references/finding-criteria.md | 132 ++++++++++++++++++ .../references/formatting-and-scripts.md | 19 +-- .../skills/skill-audit/references/patterns.md | 18 +-- .../skills/skill-audit/references/sources.md | 8 +- .../skill-author/references/contract.md | 41 +++++- .../skill-author/references/retrofit.md | 7 + .../kyberforge/skills/agent-audit/SKILL.md | 4 +- .../agent-audit/references/field-inventory.md | 6 +- .../kyberforge/skills/apm-workflow/README.md | 4 +- .../kyberforge/skills/apm-workflow/SKILL.md | 24 ++-- .../skills/apm-workflow/references/compile.md | 2 +- .../apm-workflow/references/configure.md | 2 - .../skills/apm-workflow/references/install.md | 2 +- plugins/kyberforge/skills/forge/README.md | 2 +- plugins/kyberforge/skills/forge/SKILL.md | 17 ++- .../skills/forge/references/version-bump.md | 13 +- .../kyberforge/skills/skill-audit/README.md | 3 +- .../kyberforge/skills/skill-audit/SKILL.md | 8 +- .../skill-audit/references/body-discipline.md | 51 +++---- .../references/description-quality.md | 37 +---- .../skill-audit/references/file-structure.md | 41 +++--- .../references/finding-criteria.md | 132 ++++++++++++++++++ .../references/formatting-and-scripts.md | 19 +-- .../skills/skill-audit/references/patterns.md | 18 +-- .../skills/skill-audit/references/sources.md | 8 +- .../skill-author/references/contract.md | 41 +++++- .../skill-author/references/retrofit.md | 7 + 42 files changed, 540 insertions(+), 342 deletions(-) create mode 100644 plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md create mode 100644 plugins/kyberforge/skills/skill-audit/references/finding-criteria.md diff --git a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md index 6153619..5086802 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md @@ -20,7 +20,7 @@ metadata: - Do not narrate PASS/FAIL per check while auditing. Gather findings internally and surface them only in the Step 4 report. Narrating each check as you go is the default failure mode here. - Agents take the same 250/400-character description gates as skills and **no body word gate at all** — an agent body becomes the system prompt of a fresh context, so the 900-word skill ceiling does not transfer. Judge an over-long agent body through the delegation check, never by word count. -- At plugin/APM scope the agent is a single vendor-neutral file by design: never raise a pair-consistency finding there, and provider safety stops meaning Claude-Code-versus-Copilot field leakage. +- At plugin/APM scope the agent is a single vendor-neutral file by design, so provider safety stops meaning Claude-Code-versus-Copilot field leakage there. - Vale reporting `0 files` scanned means NOT RUN, not clean. Fall back to full Step 3 judgment for every dimension it would have covered. ## Step 1 — Deterministic checks @@ -30,7 +30,7 @@ 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. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md b/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md index 9f0a083..85b27bd 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md @@ -38,9 +38,9 @@ whose vocabulary differs per harness — Claude Code names its own tools, Copilo other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent -there, never that the wrong capability is granted. Claude Code honours it for plugin subagents — -`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents -silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. +there, never that the wrong capability is granted. Claude Code honours it for plugin subagents: its +plugin agent-definition reference names the fields plugin agents silently ignore (`hooks`, +`mcpServers`, `permissionMode`), and `disallowedTools` is not among them. `disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/README.md b/plugins/kyberforge/.apm/skills/apm-workflow/README.md index 6d1e5a7..1328bed 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/README.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/README.md @@ -4,7 +4,7 @@ Authors, scaffolds, compiles, and audits apm packages and marketplaces. ## What it does -Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches by requested action to one of five reference files, each self-contained for its concern. +Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches on the resolved flow to one of five reference files; each carries that flow's traps and names a sibling file where one flow genuinely depends on another's detail. ## Before you start @@ -24,7 +24,7 @@ Requires the `apm` binary and (for runtime-driven scripts) an agent runtime alre | File | Purpose | |------|---------| -| `SKILL.md` | Dispatch table and the two gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition) | +| `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, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md index fa86cae..2cc3d55 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md @@ -1,11 +1,10 @@ --- name: apm-workflow description: > - Use when the user wants to author, scaffold, install, compile, publish, or - audit an apm package, an apm.yml manifest, or an apm marketplace, or register - someone else's to consume — even when they do not say "apm" - explicitly, e.g. "set up the package manifest". Not the apm binary itself or - an agent runtime -> `apm-install`. + Use when managing an apm package, its apm.yml manifest, or an apm + marketplace — authoring through publishing — even when the user does not say + "apm", e.g. "set up the package manifest". Not the apm binary or an agent + runtime -> `apm-install`. metadata: category: apm source_keys: @@ -16,18 +15,19 @@ metadata: - 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, and each carries the traps specific to its flow. +Read the reference file matching the resolved flow — it carries that flow's own traps, and names a sibling file wherever one flow genuinely depends on another's detail. ## Step 2 — Execute diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md index ae801ea..0780f24 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/compile.md @@ -51,7 +51,7 @@ 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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. +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 diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md index 6b7021f..808f9c2 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/configure.md @@ -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. diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md index 8f30470..c0b8f25 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/references/install.md @@ -19,4 +19,4 @@ With no arguments, resolves and installs everything declared under `dependencies `--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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. diff --git a/plugins/kyberforge/.apm/skills/forge/README.md b/plugins/kyberforge/.apm/skills/forge/README.md index aaa8bb8..d76b745 100644 --- a/plugins/kyberforge/.apm/skills/forge/README.md +++ b/plugins/kyberforge/.apm/skills/forge/README.md @@ -4,7 +4,7 @@ Guided entry point for building or improving something in any plugin of this rep ## 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 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. diff --git a/plugins/kyberforge/.apm/skills/forge/SKILL.md b/plugins/kyberforge/.apm/skills/forge/SKILL.md index 99de5bd..e3dabd2 100644 --- a/plugins/kyberforge/.apm/skills/forge/SKILL.md +++ b/plugins/kyberforge/.apm/skills/forge/SKILL.md @@ -1,12 +1,11 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something but has not yet - named the artifact type — skill, agent, plugin, or marketplace entry; "a - skill for the gitea plugin, or an agent?". Grills the intent, classifies the - artifact, then routes to the matching author skill. Do not use when the type - is already named — invoke `skill-author`, `agent-author` or `apm-workflow` - directly. + Use when the user wants to build or improve something but has not yet named + the artifact type — skill, agent, plugin, or marketplace entry; "a skill for + the gitea plugin, or an agent?". 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: @@ -18,11 +17,11 @@ metadata: ## Gotchas - 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. Every routing branch below turns on that distinction. +- 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 has already run and is available in the context. +Call `grill-with-docs` unless a grill session has already run and is available in the context. 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. @@ -48,4 +47,4 @@ When the intent spans several rows, chain the routes in dependency order — an ## Step 3 — Closing gates, common to every route - **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.** If the finished route's completion message carries no evidence of a package version bump, read `references/version-bump.md`. +- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did. diff --git a/plugins/kyberforge/.apm/skills/forge/references/version-bump.md b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md index 8a9662a..5c05039 100644 --- a/plugins/kyberforge/.apm/skills/forge/references/version-bump.md +++ b/plugins/kyberforge/.apm/skills/forge/references/version-bump.md @@ -5,9 +5,12 @@ source_keys: # Bumping the package version after a route -Reached from `SKILL.md` Step 3 when a route has finished and its completion message carries no -evidence that the package version was bumped. The author skills bump it themselves in some flows, -so check their output before doing anything here — a second bump for one artifact is wrong. +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 @@ -33,5 +36,5 @@ brief: 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 X.Y.Z to X.Y.Z to reflect the new -`<artifact-name>`." +Then report to the user: "Updated `<package-name>` version from `<old>` to `<new>` to reflect the +new `<artifact-name>`." diff --git a/plugins/kyberforge/.apm/skills/skill-audit/README.md b/plugins/kyberforge/.apm/skills/skill-audit/README.md index d46ca78..dfc5400 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/README.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/README.md @@ -6,7 +6,7 @@ Audit a skill directory against the agentskills.io specification and the house c 1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags non-imperative description openers, composition and architecture notes, vague wording, padding phrases, and "There is/are" sentence openers 2. Reads all files in the skill directory -3. Applies qualitative checks across five dimension groups, loading one rubric from `references/` per group +3. Applies qualitative checks across five dimension groups — always loading `references/finding-criteria.md`, then one rubric from `references/` per group the criteria put in play 4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to `skill-author` `validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words). @@ -35,6 +35,7 @@ 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/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 — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL | | `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints | | `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed | diff --git a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md index 5cc3a82..0c86feb 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md @@ -30,7 +30,7 @@ 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. @@ -53,9 +53,9 @@ Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/ ## Step 3 — Qualitative audit -Load a dimension's rubric before judging that dimension. Each is self-contained, and each is grounded in the agentskills.io specification plus the house context-budget contract (ADR-0020). +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body-discipline | `references/body-discipline.md` | @@ -63,7 +63,7 @@ Load a dimension's rubric before judging that dimension. Each is self-contained, | file-structure, internal-consistency | `references/file-structure.md` | | formatting, scripts | `references/formatting-and-scripts.md` | -Cite file and line number for every finding. +Each rubric is self-contained and grounded in the agentskills.io specification plus the house context budget (ADR-0020). Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index e271508..1157d83 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -80,14 +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 **237-word body** dispatching to roughly -3,400 words of references across five mutually exclusive invocations. Its whole-file count is 304 -words — cite 237 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. -Note its wiring: a three-column table (invocation, action, reference file) closed by one line, -*"Read only the reference file matching the requested action …"* That is the endorsed shape, and it -is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. +### 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 @@ -184,24 +198,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 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md index 411d178..579426c 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md @@ -80,38 +80,5 @@ description: > (`data-model` is illustrative. In a real description the target has to resolve.) -## Auditing guidance - -Flag as FAIL if: - -- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. - `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. -- **Internal mechanics appear in the description.** Any of: - - capability enumeration or a feature list; - - output-format detail ("Produces a compact findings report with Why and Fix per finding"); - - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting - shared skill", "the human-facing entry point", "replaces the old flat invocation"); - - implementation detail ("self-validates via a bundled deterministic script"). - - None of it can change a routing decision and all of it is preloaded. - `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is - judgment. This is the rule that deflates a description, so apply it before reaching for length. -- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted - as user phrasings, usually in the same order. One register, whichever routes better. -- **Descriptive rather than imperative phrasing** (`This skill ...`, `This is the ...`). - `Kyberforge.DescriptionOpener` catches any opener matching `^This`. -- **Vague capabilities** ("helps with APIs" where "parses and validates OpenAPI specs" was - available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is - judgment. -- **A boundary clause naming a target that does not resolve** to a real skill directory or agent - file in the authoring source. `validate.sh` reports the unresolved name. -- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0. -- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent - of the 400-character house ceiling above. - -Flag as SUGGESTION if: - -- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL - tier only stops outliers. Report it rather than treating a 399-character description as clean. -- A near-miss exclusion is present but targets a weak near-miss. -- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md index 805ba14..33c3531 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md @@ -24,9 +24,19 @@ knows to look at. Flag any other directory as a FAIL. ## Cross-plugin path references A plugin is copied to a cache on install, and a path that climbs out of the skill directory stops -resolving there. Flag any `../`, `../../`, or absolute repo path (`plugins/<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. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md new file mode 100644 index 0000000..73d41c0 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md @@ -0,0 +1,132 @@ +--- +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. +- **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 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. + +## body-discipline — `references/body-discipline.md` + +Flag as FAIL if: + +- A sentence answers "no" to the core test — it is padding +- The body exceeds 900 words counted body-only (`validate.sh` reports it) +- Two or more mutually exclusive flows are inlined instead of dispatched +- A Gotcha paraphrases a step in the body below it that every branch reaching the Gotcha also + reaches +- A decision point presents a menu of options with no default +- An instruction repeats content already in the description +- A prescriptive sequence is used where flexibility is fine, or the reverse + +Flag as SUGGESTION if: + +- The body exceeds 600 words counted body-only but stays at or under 900 +- The Gotchas section carries more than five entries +- The Gotchas section exceeds 25% of the body +- A rationale is missing from an include/exclude rule — present but unexplained +- Gotchas are correct but placed late in the body rather than near the top +- Content that only one branch reaches is inlined where a `references/` file would serve + +## patterns — `references/patterns.md` + +Flag as FAIL if: + +- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable + assumption +- An inner code fence is unescaped inside a markdown block, breaking the render +- A checklist wraps a single step +- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form +- The agent must produce a specific format and no output template is given + +Flag as SUGGESTION if: + +- Gotchas are correctly formed but placed late in the body +- An output template is present but permissive where the consumer needs it exact +- A conditional reference names a trigger that is real but broader than the branch it guards + +## file-structure and internal-consistency — `references/file-structure.md` + +Flag as FAIL if: + +- A directory outside the four permitted ones exists +- Test files sit in `scripts/` +- A non-spec file sits at the skill root +- A path that resolves outside the skill directory appears outside the two exempt locations, in + prose rather than in a fenced example +- `tests/` exists but `tests/README.md` is missing or does not document its repo-level dependency +- `README.md` is absent, or its file table has a missing or stale row +- `SKILL.md` describes a script invocation the script does not accept + +Flag as SUGGESTION if: + +- An optional directory exists but holds only a placeholder README +- `README.md` is accurate but describes a file's purpose more thinly than `SKILL.md` does + +## formatting and scripts — `references/formatting-and-scripts.md` + +Flag as FAIL if: + +- A script prompts interactively, in any form +- A script exposes no `--help` +- A destructive script has no `--dry-run` +- Data and diagnostics share a stream, so the output cannot be piped +- A relative path named in the body does not resolve +- Heading levels are inconsistent enough to break the document's structure + +Flag as SUGGESTION if: + +- Exit codes are meaningful but undocumented in `--help` +- A code block is untagged where a language applies +- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear +- List indentation or section spacing is inconsistent without breaking the render diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md index 9462bdf..0b9d23e 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md @@ -44,20 +44,5 @@ follow from that: undocumented one is a coin flip. - **`--dry-run` present for destructive operations.** -## Auditing guidance - -Flag as FAIL if: - -- A script prompts interactively, in any form -- A script exposes no `--help` -- A destructive script has no `--dry-run` -- Data and diagnostics share a stream, so the output cannot be piped -- A relative path named in the body does not resolve -- Heading levels are inconsistent enough to break the document's structure - -Flag as SUGGESTION if: - -- Exit codes are meaningful but undocumented in `--help` -- A code block is untagged where a language applies -- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear -- List indentation or section spacing is inconsistent without breaking the render +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md index fb144ca..80cffa1 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md @@ -50,19 +50,5 @@ forms are judgment. `references/` when only one dispatch branch produces that output. A template inlined for a branch most invocations never take is body-discipline padding. -## Auditing guidance - -Flag as FAIL if: - -- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable - assumption -- An inner code fence is unescaped inside a markdown block, breaking the render -- A checklist wraps a single step -- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form -- The agent must produce a specific format and no output template is given - -Flag as SUGGESTION if: - -- Gotchas are correctly formed but placed late in the body -- An output template is present but permissive where the consumer needs it exact -- A conditional reference names a trigger that is real but broader than the branch it guards +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md b/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md index fd89ca7..a01c5dd 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md @@ -15,7 +15,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Complete SKILL.md format specification — frontmatter fields, constraints, body content, optional directories, progressive disclosure levels, file references, validation -- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-best-practices @@ -23,7 +23,7 @@ - **URL:** https://agentskills.io/skill-creation/best-practices.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Best practices for skill creators — starting from real expertise, spending context wisely, calibrating control, instruction patterns (gotchas, templates, checklists, validation loops) -- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-optimizing-descriptions @@ -31,7 +31,7 @@ - **URL:** https://agentskills.io/skill-creation/optimizing-descriptions.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** How to systematically test and improve skill descriptions for triggering accuracy — eval queries, trigger rate testing, train/validation splits, optimization loop -- **Contributing files:** SKILL.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/description-quality.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-evaluating-skills @@ -47,7 +47,7 @@ - **URL:** https://agentskills.io/skill-creation/using-scripts.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Using scripts in skills — one-off commands, self-contained scripts with inline dependencies, designing scripts for agentic use (no interactive prompts, --help, structured output, idempotency) -- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-quickstart diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md index 15bbab4..faf0479 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md @@ -63,6 +63,11 @@ 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`. + **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 +123,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 **237-word body** dispatching to 3,416 words of -references. Calibrate against 237: that file's whole-file count is 304 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 +149,20 @@ the frontmatter's closing `---`. ## Gotchas section - Each entry must state a fact that **contradicts a reasonable default** — something the agent - gets wrong by acting sensibly. "Never commit secrets" is not one; the agent already knows. + gets wrong by acting sensibly. "Write a descriptive commit message" is not one; the agent does it + unprompted and nothing in the environment argues against it. A safety gate is a different case, + even where the agent knows the rule — see the paraphrase bullet below. - More than five entries is a SUGGESTION — five is the guideline, not a ceiling. -- A Gotcha that paraphrases a step in the body below it is a **FAIL**. If the rule is already a - step, it is not a gotcha. +- A Gotcha that paraphrases a step in the body below it is a **FAIL**, but deleting it is correct + only when the surviving copy is **reachable from every branch that reaches the Gotcha**. In a + dispatch body it often is not: each flow file loads alone, so a step in one is invisible to an + invocation that took another branch. Where the restated rule is a safety gate more than one flow + needs, move it into the body's common-gates section instead of dropping it. `git-commits` v0.1.2 + is the worked failure: the retrofit deleted its always-loaded "never commit secrets" Gotcha in + favour of a step in one flow file, and left the history-rewrite branch — which stages and + `--amend`s, committing new content exactly as a fresh commit does — with no such check anywhere + in its loaded context, against this repo's governance hard prohibitions. v0.1.3 carries the rule + as a gate on every flow. - A Gotchas section exceeding 25% of the body is a SUGGESTION. - Place the section near the top — a gotcha read after the mistake is worthless. diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md index 7eeae03..6cec846 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md @@ -96,6 +96,13 @@ them for you. After every retrofit that adds, removes or renames a file: content moved into it, and remove any file the retrofit deleted. This is the one that gets missed: `sources.md` keeps citing sections of `SKILL.md` that no longer exist, the provenance check still exits 0, and the stale claim survives review. +- [ ] **Reachability of every relocated gate.** For each Gotcha or gate the retrofit moved out of + the body, list the flows that need it and confirm each one reaches the surviving copy. A gate + that lands in a single flow file is invisible to every other branch, and no gate detects + that: `/skill-audit` reads whichever file it was handed, and the word counts improve either + way. Where more than one flow needs it, the copy belongs in the body's common-gates section, + not in a flow file. Grep the skill for the gate's key term and check every branch that hits + zero. - [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new file as missing `source_keys`. diff --git a/plugins/kyberforge/skills/agent-audit/SKILL.md b/plugins/kyberforge/skills/agent-audit/SKILL.md index 6153619..5086802 100644 --- a/plugins/kyberforge/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/skills/agent-audit/SKILL.md @@ -20,7 +20,7 @@ metadata: - Do not narrate PASS/FAIL per check while auditing. Gather findings internally and surface them only in the Step 4 report. Narrating each check as you go is the default failure mode here. - Agents take the same 250/400-character description gates as skills and **no body word gate at all** — an agent body becomes the system prompt of a fresh context, so the 900-word skill ceiling does not transfer. Judge an over-long agent body through the delegation check, never by word count. -- At plugin/APM scope the agent is a single vendor-neutral file by design: never raise a pair-consistency finding there, and provider safety stops meaning Claude-Code-versus-Copilot field leakage. +- At plugin/APM scope the agent is a single vendor-neutral file by design, so provider safety stops meaning Claude-Code-versus-Copilot field leakage there. - Vale reporting `0 files` scanned means NOT RUN, not clean. Fall back to full Step 3 judgment for every dimension it would have covered. ## Step 1 — Deterministic checks @@ -30,7 +30,7 @@ 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. diff --git a/plugins/kyberforge/skills/agent-audit/references/field-inventory.md b/plugins/kyberforge/skills/agent-audit/references/field-inventory.md index 9f0a083..85b27bd 100644 --- a/plugins/kyberforge/skills/agent-audit/references/field-inventory.md +++ b/plugins/kyberforge/skills/agent-audit/references/field-inventory.md @@ -38,9 +38,9 @@ whose vocabulary differs per harness — Claude Code names its own tools, Copilo other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent -there, never that the wrong capability is granted. Claude Code honours it for plugin subagents — -`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents -silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. +there, never that the wrong capability is granted. Claude Code honours it for plugin subagents: its +plugin agent-definition reference names the fields plugin agents silently ignore (`hooks`, +`mcpServers`, `permissionMode`), and `disallowedTools` is not among them. `disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`. diff --git a/plugins/kyberforge/skills/apm-workflow/README.md b/plugins/kyberforge/skills/apm-workflow/README.md index 6d1e5a7..1328bed 100644 --- a/plugins/kyberforge/skills/apm-workflow/README.md +++ b/plugins/kyberforge/skills/apm-workflow/README.md @@ -4,7 +4,7 @@ Authors, scaffolds, compiles, and audits apm packages and marketplaces. ## What it does -Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches by requested action to one of five reference files, each self-contained for its concern. +Covers the apm.yml lifecycle a session moves through repeatedly: configuring/scaffolding a package manifest, resolving/fetching its declared dependencies, building or registering a marketplace, compiling/packing/publishing a distributable, and validating integrity via apm audit. Dispatches on the resolved flow to one of five reference files; each carries that flow's traps and names a sibling file where one flow genuinely depends on another's detail. ## Before you start @@ -24,7 +24,7 @@ Requires the `apm` binary and (for runtime-driven scripts) an agent runtime alre | File | Purpose | |------|---------| -| `SKILL.md` | Dispatch table and the two gotchas common to every branch (MCP secret indirection, the `experimental enable registries` precondition) | +| `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, `marketplace add` vs `package add`, package registration, versioning, Claude Code reserved-name/publish-confirm gotchas | diff --git a/plugins/kyberforge/skills/apm-workflow/SKILL.md b/plugins/kyberforge/skills/apm-workflow/SKILL.md index fa86cae..2cc3d55 100644 --- a/plugins/kyberforge/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/skills/apm-workflow/SKILL.md @@ -1,11 +1,10 @@ --- name: apm-workflow description: > - Use when the user wants to author, scaffold, install, compile, publish, or - audit an apm package, an apm.yml manifest, or an apm marketplace, or register - someone else's to consume — even when they do not say "apm" - explicitly, e.g. "set up the package manifest". Not the apm binary itself or - an agent runtime -> `apm-install`. + Use when managing an apm package, its apm.yml manifest, or an apm + marketplace — authoring through publishing — even when the user does not say + "apm", e.g. "set up the package manifest". Not the apm binary or an agent + runtime -> `apm-install`. metadata: category: apm source_keys: @@ -16,18 +15,19 @@ metadata: - 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, and each carries the traps specific to its flow. +Read the reference file matching the resolved flow — it carries that flow's own traps, and names a sibling file wherever one flow genuinely depends on another's detail. ## Step 2 — Execute diff --git a/plugins/kyberforge/skills/apm-workflow/references/compile.md b/plugins/kyberforge/skills/apm-workflow/references/compile.md index ae801ea..0780f24 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/compile.md +++ b/plugins/kyberforge/skills/apm-workflow/references/compile.md @@ -51,7 +51,7 @@ 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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. +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 diff --git a/plugins/kyberforge/skills/apm-workflow/references/configure.md b/plugins/kyberforge/skills/apm-workflow/references/configure.md index 6b7021f..808f9c2 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/configure.md +++ b/plugins/kyberforge/skills/apm-workflow/references/configure.md @@ -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. diff --git a/plugins/kyberforge/skills/apm-workflow/references/install.md b/plugins/kyberforge/skills/apm-workflow/references/install.md index 8f30470..c0b8f25 100644 --- a/plugins/kyberforge/skills/apm-workflow/references/install.md +++ b/plugins/kyberforge/skills/apm-workflow/references/install.md @@ -19,4 +19,4 @@ With no arguments, resolves and installs everything declared under `dependencies `--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 `references/configure.md`'s Gotchas for the full precondition and its silent-no-op failure mode. +If a `PACKAGE_REF` resolves through a named registry rather than a plain git source, `apm experimental enable registries` must already have been run — see `SKILL.md`'s Gotchas for the precondition and its silent-no-op failure mode. diff --git a/plugins/kyberforge/skills/forge/README.md b/plugins/kyberforge/skills/forge/README.md index aaa8bb8..d76b745 100644 --- a/plugins/kyberforge/skills/forge/README.md +++ b/plugins/kyberforge/skills/forge/README.md @@ -4,7 +4,7 @@ Guided entry point for building or improving something in any plugin of this rep ## 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 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. diff --git a/plugins/kyberforge/skills/forge/SKILL.md b/plugins/kyberforge/skills/forge/SKILL.md index 99de5bd..e3dabd2 100644 --- a/plugins/kyberforge/skills/forge/SKILL.md +++ b/plugins/kyberforge/skills/forge/SKILL.md @@ -1,12 +1,11 @@ --- name: forge description: > - Use when the user wants to build, add, or improve something but has not yet - named the artifact type — skill, agent, plugin, or marketplace entry; "a - skill for the gitea plugin, or an agent?". Grills the intent, classifies the - artifact, then routes to the matching author skill. Do not use when the type - is already named — invoke `skill-author`, `agent-author` or `apm-workflow` - directly. + Use when the user wants to build or improve something but has not yet named + the artifact type — skill, agent, plugin, or marketplace entry; "a skill for + the gitea plugin, or an agent?". 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: @@ -18,11 +17,11 @@ metadata: ## Gotchas - 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. Every routing branch below turns on that distinction. +- 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 has already run and is available in the context. +Call `grill-with-docs` unless a grill session has already run and is available in the context. 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. @@ -48,4 +47,4 @@ When the intent spans several rows, chain the routes in dependency order — an ## Step 3 — Closing gates, common to every route - **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.** If the finished route's completion message carries no evidence of a package version bump, read `references/version-bump.md`. +- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did. diff --git a/plugins/kyberforge/skills/forge/references/version-bump.md b/plugins/kyberforge/skills/forge/references/version-bump.md index 8a9662a..5c05039 100644 --- a/plugins/kyberforge/skills/forge/references/version-bump.md +++ b/plugins/kyberforge/skills/forge/references/version-bump.md @@ -5,9 +5,12 @@ source_keys: # Bumping the package version after a route -Reached from `SKILL.md` Step 3 when a route has finished and its completion message carries no -evidence that the package version was bumped. The author skills bump it themselves in some flows, -so check their output before doing anything here — a second bump for one artifact is wrong. +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 @@ -33,5 +36,5 @@ brief: 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 X.Y.Z to X.Y.Z to reflect the new -`<artifact-name>`." +Then report to the user: "Updated `<package-name>` version from `<old>` to `<new>` to reflect the +new `<artifact-name>`." diff --git a/plugins/kyberforge/skills/skill-audit/README.md b/plugins/kyberforge/skills/skill-audit/README.md index d46ca78..dfc5400 100644 --- a/plugins/kyberforge/skills/skill-audit/README.md +++ b/plugins/kyberforge/skills/skill-audit/README.md @@ -6,7 +6,7 @@ Audit a skill directory against the agentskills.io specification and the house c 1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags non-imperative description openers, composition and architecture notes, vague wording, padding phrases, and "There is/are" sentence openers 2. Reads all files in the skill directory -3. Applies qualitative checks across five dimension groups, loading one rubric from `references/` per group +3. Applies qualitative checks across five dimension groups — always loading `references/finding-criteria.md`, then one rubric from `references/` per group the criteria put in play 4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to `skill-author` `validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words). @@ -35,6 +35,7 @@ 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/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 — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL | | `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints | | `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed | diff --git a/plugins/kyberforge/skills/skill-audit/SKILL.md b/plugins/kyberforge/skills/skill-audit/SKILL.md index 5cc3a82..0c86feb 100644 --- a/plugins/kyberforge/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/skills/skill-audit/SKILL.md @@ -30,7 +30,7 @@ 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. @@ -53,9 +53,9 @@ Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/ ## Step 3 — Qualitative audit -Load a dimension's rubric before judging that dimension. Each is self-contained, and each is grounded in the agentskills.io specification plus the house context-budget contract (ADR-0020). +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body-discipline | `references/body-discipline.md` | @@ -63,7 +63,7 @@ Load a dimension's rubric before judging that dimension. Each is self-contained, | file-structure, internal-consistency | `references/file-structure.md` | | formatting, scripts | `references/formatting-and-scripts.md` | -Cite file and line number for every finding. +Each rubric is self-contained and grounded in the agentskills.io specification plus the house context budget (ADR-0020). Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index e271508..1157d83 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -80,14 +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 **237-word body** dispatching to roughly -3,400 words of references across five mutually exclusive invocations. Its whole-file count is 304 -words — cite 237 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. -Note its wiring: a three-column table (invocation, action, reference file) closed by one line, -*"Read only the reference file matching the requested action …"* That is the endorsed shape, and it -is why the literal-conditional requirement above exempts a body that dispatches. Do not flag it. +### 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 @@ -184,24 +198,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 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 +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/skills/skill-audit/references/description-quality.md index 411d178..579426c 100644 --- a/plugins/kyberforge/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/skills/skill-audit/references/description-quality.md @@ -80,38 +80,5 @@ description: > (`data-model` is illustrative. In a real description the target has to resolve.) -## Auditing guidance - -Flag as FAIL if: - -- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. - `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. -- **Internal mechanics appear in the description.** Any of: - - capability enumeration or a feature list; - - output-format detail ("Produces a compact findings report with Why and Fix per finding"); - - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting - shared skill", "the human-facing entry point", "replaces the old flat invocation"); - - implementation detail ("self-validates via a bundled deterministic script"). - - None of it can change a routing decision and all of it is preloaded. - `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is - judgment. This is the rule that deflates a description, so apply it before reaching for length. -- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted - as user phrasings, usually in the same order. One register, whichever routes better. -- **Descriptive rather than imperative phrasing** (`This skill ...`, `This is the ...`). - `Kyberforge.DescriptionOpener` catches any opener matching `^This`. -- **Vague capabilities** ("helps with APIs" where "parses and validates OpenAPI specs" was - available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is - judgment. -- **A boundary clause naming a target that does not resolve** to a real skill directory or agent - file in the authoring source. `validate.sh` reports the unresolved name. -- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0. -- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent - of the 400-character house ceiling above. - -Flag as SUGGESTION if: - -- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL - tier only stops outliers. Report it rather than treating a 399-character description as clean. -- A near-miss exclusion is present but targets a weak near-miss. -- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/file-structure.md b/plugins/kyberforge/skills/skill-audit/references/file-structure.md index 805ba14..33c3531 100644 --- a/plugins/kyberforge/skills/skill-audit/references/file-structure.md +++ b/plugins/kyberforge/skills/skill-audit/references/file-structure.md @@ -24,9 +24,19 @@ knows to look at. Flag any other directory as a FAIL. ## Cross-plugin path references A plugin is copied to a cache on install, and a path that climbs out of the skill directory stops -resolving there. Flag any `../`, `../../`, or absolute repo path (`plugins/<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. diff --git a/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md new file mode 100644 index 0000000..73d41c0 --- /dev/null +++ b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md @@ -0,0 +1,132 @@ +--- +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. +- **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 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. + +## body-discipline — `references/body-discipline.md` + +Flag as FAIL if: + +- A sentence answers "no" to the core test — it is padding +- The body exceeds 900 words counted body-only (`validate.sh` reports it) +- Two or more mutually exclusive flows are inlined instead of dispatched +- A Gotcha paraphrases a step in the body below it that every branch reaching the Gotcha also + reaches +- A decision point presents a menu of options with no default +- An instruction repeats content already in the description +- A prescriptive sequence is used where flexibility is fine, or the reverse + +Flag as SUGGESTION if: + +- The body exceeds 600 words counted body-only but stays at or under 900 +- The Gotchas section carries more than five entries +- The Gotchas section exceeds 25% of the body +- A rationale is missing from an include/exclude rule — present but unexplained +- Gotchas are correct but placed late in the body rather than near the top +- Content that only one branch reaches is inlined where a `references/` file would serve + +## patterns — `references/patterns.md` + +Flag as FAIL if: + +- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable + assumption +- An inner code fence is unescaped inside a markdown block, breaking the render +- A checklist wraps a single step +- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form +- The agent must produce a specific format and no output template is given + +Flag as SUGGESTION if: + +- Gotchas are correctly formed but placed late in the body +- An output template is present but permissive where the consumer needs it exact +- A conditional reference names a trigger that is real but broader than the branch it guards + +## file-structure and internal-consistency — `references/file-structure.md` + +Flag as FAIL if: + +- A directory outside the four permitted ones exists +- Test files sit in `scripts/` +- A non-spec file sits at the skill root +- A path that resolves outside the skill directory appears outside the two exempt locations, in + prose rather than in a fenced example +- `tests/` exists but `tests/README.md` is missing or does not document its repo-level dependency +- `README.md` is absent, or its file table has a missing or stale row +- `SKILL.md` describes a script invocation the script does not accept + +Flag as SUGGESTION if: + +- An optional directory exists but holds only a placeholder README +- `README.md` is accurate but describes a file's purpose more thinly than `SKILL.md` does + +## formatting and scripts — `references/formatting-and-scripts.md` + +Flag as FAIL if: + +- A script prompts interactively, in any form +- A script exposes no `--help` +- A destructive script has no `--dry-run` +- Data and diagnostics share a stream, so the output cannot be piped +- A relative path named in the body does not resolve +- Heading levels are inconsistent enough to break the document's structure + +Flag as SUGGESTION if: + +- Exit codes are meaningful but undocumented in `--help` +- A code block is untagged where a language applies +- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear +- List indentation or section spacing is inconsistent without breaking the render diff --git a/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md b/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md index 9462bdf..0b9d23e 100644 --- a/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md +++ b/plugins/kyberforge/skills/skill-audit/references/formatting-and-scripts.md @@ -44,20 +44,5 @@ follow from that: undocumented one is a coin flip. - **`--dry-run` present for destructive operations.** -## Auditing guidance - -Flag as FAIL if: - -- A script prompts interactively, in any form -- A script exposes no `--help` -- A destructive script has no `--dry-run` -- Data and diagnostics share a stream, so the output cannot be piped -- A relative path named in the body does not resolve -- Heading levels are inconsistent enough to break the document's structure - -Flag as SUGGESTION if: - -- Exit codes are meaningful but undocumented in `--help` -- A code block is untagged where a language applies -- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear -- List indentation or section spacing is inconsistent without breaking the render +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/patterns.md b/plugins/kyberforge/skills/skill-audit/references/patterns.md index fb144ca..80cffa1 100644 --- a/plugins/kyberforge/skills/skill-audit/references/patterns.md +++ b/plugins/kyberforge/skills/skill-audit/references/patterns.md @@ -50,19 +50,5 @@ forms are judgment. `references/` when only one dispatch branch produces that output. A template inlined for a branch most invocations never take is body-discipline padding. -## Auditing guidance - -Flag as FAIL if: - -- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable - assumption -- An inner code fence is unescaped inside a markdown block, breaking the render -- A checklist wraps a single step -- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form -- The agent must produce a specific format and no output template is given - -Flag as SUGGESTION if: - -- Gotchas are correctly formed but placed late in the body -- An output template is present but permissive where the consumer needs it exact -- A conditional reference names a trigger that is real but broader than the branch it guards +The FAIL and SUGGESTION criteria for this dimension live in `references/finding-criteria.md`, +which Step 3 loads on every run. diff --git a/plugins/kyberforge/skills/skill-audit/references/sources.md b/plugins/kyberforge/skills/skill-audit/references/sources.md index fd89ca7..a01c5dd 100644 --- a/plugins/kyberforge/skills/skill-audit/references/sources.md +++ b/plugins/kyberforge/skills/skill-audit/references/sources.md @@ -15,7 +15,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Complete SKILL.md format specification — frontmatter fields, constraints, body content, optional directories, progressive disclosure levels, file references, validation -- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-best-practices @@ -23,7 +23,7 @@ - **URL:** https://agentskills.io/skill-creation/best-practices.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Best practices for skill creators — starting from real expertise, spending context wisely, calibrating control, instruction patterns (gotchas, templates, checklists, validation loops) -- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-optimizing-descriptions @@ -31,7 +31,7 @@ - **URL:** https://agentskills.io/skill-creation/optimizing-descriptions.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** How to systematically test and improve skill descriptions for triggering accuracy — eval queries, trigger rate testing, train/validation splits, optimization loop -- **Contributing files:** SKILL.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/description-quality.md, references/finding-criteria.md - **Status:** `extracted` ## agentskills-evaluating-skills @@ -47,7 +47,7 @@ - **URL:** https://agentskills.io/skill-creation/using-scripts.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Using scripts in skills — one-off commands, self-contained scripts with inline dependencies, designing scripts for agentic use (no interactive prompts, --help, structured output, idempotency) -- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/finding-criteria.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-quickstart diff --git a/plugins/kyberforge/skills/skill-author/references/contract.md b/plugins/kyberforge/skills/skill-author/references/contract.md index 15bbab4..faf0479 100644 --- a/plugins/kyberforge/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/skills/skill-author/references/contract.md @@ -63,6 +63,11 @@ 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`. + **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 +123,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 **237-word body** dispatching to 3,416 words of -references. Calibrate against 237: that file's whole-file count is 304 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 +149,20 @@ the frontmatter's closing `---`. ## Gotchas section - Each entry must state a fact that **contradicts a reasonable default** — something the agent - gets wrong by acting sensibly. "Never commit secrets" is not one; the agent already knows. + gets wrong by acting sensibly. "Write a descriptive commit message" is not one; the agent does it + unprompted and nothing in the environment argues against it. A safety gate is a different case, + even where the agent knows the rule — see the paraphrase bullet below. - More than five entries is a SUGGESTION — five is the guideline, not a ceiling. -- A Gotcha that paraphrases a step in the body below it is a **FAIL**. If the rule is already a - step, it is not a gotcha. +- A Gotcha that paraphrases a step in the body below it is a **FAIL**, but deleting it is correct + only when the surviving copy is **reachable from every branch that reaches the Gotcha**. In a + dispatch body it often is not: each flow file loads alone, so a step in one is invisible to an + invocation that took another branch. Where the restated rule is a safety gate more than one flow + needs, move it into the body's common-gates section instead of dropping it. `git-commits` v0.1.2 + is the worked failure: the retrofit deleted its always-loaded "never commit secrets" Gotcha in + favour of a step in one flow file, and left the history-rewrite branch — which stages and + `--amend`s, committing new content exactly as a fresh commit does — with no such check anywhere + in its loaded context, against this repo's governance hard prohibitions. v0.1.3 carries the rule + as a gate on every flow. - A Gotchas section exceeding 25% of the body is a SUGGESTION. - Place the section near the top — a gotcha read after the mistake is worthless. diff --git a/plugins/kyberforge/skills/skill-author/references/retrofit.md b/plugins/kyberforge/skills/skill-author/references/retrofit.md index 7eeae03..6cec846 100644 --- a/plugins/kyberforge/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/skills/skill-author/references/retrofit.md @@ -96,6 +96,13 @@ them for you. After every retrofit that adds, removes or renames a file: content moved into it, and remove any file the retrofit deleted. This is the one that gets missed: `sources.md` keeps citing sections of `SKILL.md` that no longer exist, the provenance check still exits 0, and the stale claim survives review. +- [ ] **Reachability of every relocated gate.** For each Gotcha or gate the retrofit moved out of + the body, list the flows that need it and confirm each one reaches the surviving copy. A gate + that lands in a single flow file is invisible to every other branch, and no gate detects + that: `/skill-audit` reads whichever file it was handed, and the word counts improve either + way. Where more than one flow needs it, the copy belongs in the body's common-gates section, + not in a flow file. Grep the skill for the gate's key term and check every branch that hits + zero. - [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new file as missing `source_keys`. -- 2.43.0 From 131b89733b0c96eda871286bb81fc9fc749b2599 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:23 +0000 Subject: [PATCH 68/89] fix(apm-orchestrate): correct the marketplace add-package direction and trim duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apm marketplace package add` rejects a local path — it registers a remote package reference — but the agent routed local-package registration to it, so that dispatch could only fail. Local registration is a manifest edit and now routes to `edit-config`. The agent also carried its own copy of the `type:` guidance and a numbered workflow that restated the dispatch procedure below it; both drift from apm-workflow independently. `type:` correctness is delegated where it belongs, and the description is rewritten to a trigger plus the apm-install boundary rather than a restatement of the body. Addresses #120. --- .../.apm/agents/apm-orchestrate.agent.md | 21 ++++++------------- .../agents/apm-orchestrate.agent.md | 21 ++++++------------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md index ad736b7..7cd26ee 100644 --- a/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/.apm/agents/apm-orchestrate.agent.md @@ -1,7 +1,7 @@ --- name: apm-orchestrate -description: Orchestrates apm package/marketplace operations for other agents. Invoke when a caller needs a multi-step apm operation (scaffold a package, register it into a marketplace, compile/pack/publish, audit) coordinated across the apm-workflow skill with safety gates, session context, and structured results — especially fanning the same operation out across multiple packages in a monorepo. +description: Use when an agent caller needs a multi-step apm package or marketplace operation dispatched and safety-gated, including the same operation fanned out across a monorepo. Not apm binary or agent-runtime install -> apm-install. source_keys: - context7-microsoft-apm @@ -9,7 +9,7 @@ source_keys: disallowedTools: Edit, Write, NotebookEdit --- -You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. +You are the orchestrator for apm package/marketplace operations. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. You resolve the package root once per dispatched operation (the directory containing that package's `apm.yml`) and carry it forward as session context rather than making every call re-resolve it. @@ -20,25 +20,16 @@ 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/references/marketplace.md Gotchas for why the two are easy to conflate. -- `apm.yml`'s `type:` field routes processing (native skill install vs AGENTS.md compilation); it never validates `.apm/` content, and a mismatch is silent rather than an error. When scaffolding (`init-package`), set `type:` to cover every primitive the package will ship, and report the deployed output rather than the exit code — see apm-workflow/references/configure.md Gotchas. +- `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/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 diff --git a/plugins/kyberforge/agents/apm-orchestrate.agent.md b/plugins/kyberforge/agents/apm-orchestrate.agent.md index ad736b7..7cd26ee 100644 --- a/plugins/kyberforge/agents/apm-orchestrate.agent.md +++ b/plugins/kyberforge/agents/apm-orchestrate.agent.md @@ -1,7 +1,7 @@ --- name: apm-orchestrate -description: Orchestrates apm package/marketplace operations for other agents. Invoke when a caller needs a multi-step apm operation (scaffold a package, register it into a marketplace, compile/pack/publish, audit) coordinated across the apm-workflow skill with safety gates, session context, and structured results — especially fanning the same operation out across multiple packages in a monorepo. +description: Use when an agent caller needs a multi-step apm package or marketplace operation dispatched and safety-gated, including the same operation fanned out across a monorepo. Not apm binary or agent-runtime install -> apm-install. source_keys: - context7-microsoft-apm @@ -9,7 +9,7 @@ source_keys: disallowedTools: Edit, Write, NotebookEdit --- -You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. +You are the orchestrator for apm package/marketplace operations. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make. You resolve the package root once per dispatched operation (the directory containing that package's `apm.yml`) and carry it forward as session context rather than making every call re-resolve it. @@ -20,25 +20,16 @@ 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/references/marketplace.md Gotchas for why the two are easy to conflate. -- `apm.yml`'s `type:` field routes processing (native skill install vs AGENTS.md compilation); it never validates `.apm/` content, and a mismatch is silent rather than an error. When scaffolding (`init-package`), set `type:` to cover every primitive the package will ship, and report the deployed output rather than the exit code — see apm-workflow/references/configure.md Gotchas. +- `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/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 -- 2.43.0 From ee248ff5a5e660c17792eb0fe5219e64d3f99e6f Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:44 +0000 Subject: [PATCH 69/89] docs: describe the boundary check's new outcomes and de-pin stale counts The gate spec still described the routing-target check as pass/fail against a single-arrow clause. It now documents the three outcomes, the hand-invocation carve-out and the one-arrow rule, so a contributor hitting a SUGGESTION can tell whether it is a real defect or accepted phrasing. Both files cited skill counts and source line numbers that go stale on the next edit and were already wrong; those citations are removed rather than refreshed. --- AGENTS.md | 2 +- docs/spec/gates.md | 156 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 140 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02ed5da..32fd71d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Issue #99 retrofitted them plugin by plugin — `kyberforge` was the last plugin wave, followed by corpus-wide passes and two rounds of independent audit fixes. Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. That blind spot has **two** independent causes and closing either alone changes nothing: the `Kyberforge` style is scoped `[**/SKILL.md]`, *and* the `vale-audit-prefilter-skill` hook filters on `files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'`, so a reference file is never handed to Vale whatever the style says (#117). Check both gates: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Retrofitted plugin by plugin under #99 (see `docs/spec/gates.md`). Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. That blind spot has **two** independent causes and closing either alone changes nothing: the `Kyberforge` style is scoped `[**/SKILL.md]` (the cause #117 records), *and* the `vale-audit-prefilter-skill` hook filters on `files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'`, so a reference file is never handed to Vale whatever the style says. Check both gates: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. diff --git a/docs/spec/gates.md b/docs/spec/gates.md index 7fb8ea9..c00048a 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -141,7 +141,7 @@ A boundary-clause target that resolves to no skill or agent has **three** possib | Verdict | When | |---|---| | **SUGGESTION** — the default | the target does not resolve and neither promotion condition below holds | -| **blocking ERROR** | the target is **terminal** (not a compound modifier) **and** either written in route notation (`/name` for any name; `-> name` only when the name is hyphenated — see the gap below) **or** corroborated by another target in the same sentence that *does* resolve | +| **blocking ERROR** | the target is **terminal** (not a compound modifier) **and** either written in route notation (`/name` for any name; a bare `-> name` only when the name is hyphenated, a backticked `` -> `name` `` for any — see the gap below) **or** corroborated by another target in the same sentence that *does* resolve | | **INFO, "DID NOT RUN"** | no skill universe could be determined for the path at all — the targets are named and left unchecked, exit 0 | The default is deliberately soft because a hyphenated word in a boundary clause is as likely to be a @@ -149,14 +149,25 @@ tool, a file format or an English compound as a route: "pre-commit hooks" is pro never reaches the check at all, being a compound modifier rather than a terminal name. The SUGGESTION text says how to opt in — write it as `/name` or `-> name` and it gets checked properly. -**Known gap: the arrow form only works for hyphenated names.** Target extraction is built on -`NAME_HYPH` (`scripts/skill-size-check.sh:543`), which requires at least one hyphen, and -`ARROW_BOUNDARY` (`:561`) inherits that. So `-> gitea-prs` is extracted and checked, while -`-> triage` is not extracted at all — no ERROR, no SUGGESTION, exit 0. The unicode arrow `→` is not -recognised in either case. This makes the SUGGESTION's own advice unsafe for a single-word skill: -taking it silences the finding rather than checking it. `/name` has no such restriction and is the -form to prefer. Tracked as a defect; `tests/test-adr0020-targets.sh` has one arrow case and its -target happens to be hyphenated, so nothing currently covers this. +**Known gap: a BARE arrow target must be hyphenated.** Target extraction is built on `NAME_HYPH` in +`scripts/skill-size-check.sh`, which requires at least one hyphen, and `ARROW_BOUNDARY` inherits +that. So `Not X -> gitea-prs` is extracted and checked, while `Not X -> triage` yields no target. +The exclusion is deliberate, not an oversight: `research`, `triage`, `forge`, `prototype` and `tdd` +are all real skill names *and* ordinary English, so a bare single-word rule would flag most of the +corpus. The marked spellings carry no such restriction — `` `triage` `` and `/triage` are both +extracted — and are the forms to prefer. **Both arrow spellings are recognised:** `ARROW_MARKED`, +`ARROW_BOUNDARY` and `BOUNDARY_ARROW` are each built from `(?:->|→)`, so the unicode arrow `→` +behaves exactly like `->` in every case below. Cite these constants by symbol name, never by line +number: the script moves often enough that a pinned line lands a reader in an unrelated comment +block and reads as plausible. + +**The gap is no longer silent.** It used to be exactly that — no ERROR, no SUGGESTION, exit 0 — which +made the dangling-target SUGGESTION's own advice unsafe for a single-word skill: taking it silenced +the finding instead of checking it. `boundary_clause_status()` now separates the case out and +reports it as `unparsed` (see below), naming the parse failure and the two spellings that fix it. +The target is still not *resolved*; the author is now told so rather than left with a green gate. +`tests/test-adr0020-targets.sh` covers both directions (`arrow-single-word-target` and the silent +control `arrow-single-word-marked`). Corroboration is what makes the soft default safe: a sentence whose *other* target resolves is demonstrably a routing sentence, so a sibling that does not resolve is a typo rather than a noun, and @@ -200,17 +211,109 @@ through `.claude/skills/` alone, so **the same commit measured 2 dangling target machine and 6 on a fresh clone**. A gate shipping hot with no baseline cannot give two answers. Verified fixed: running the hook over a tree holding only `plugins/` and the root `apm.yml`, with no -`.claude/` or `.agents/` anywhere, produces findings identical to the working tree — **26 description -FAILs, 9 body FAILs, 2 dangling targets, 0 missing references, 58 SUGGESTIONs**. +`.claude/` or `.agents/` anywhere, produced findings identical to the working tree. The figures that +reproduction recorded — 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 missing references +— are the pre-retrofit corpus as it stood when the experiment was run, kept here as the evidence for +the install-independence claim. They are not current: the retrofit under #99 took the first three to +zero. What the experiment establishes is that the two trees agree, not what either measured. + +### Boundary-clause detection: three outcomes, not two + +`boundary_clause_status()` returns one of three values, and the two findings get separate messages: + +| Status | When | Reported as | +|---|---|---| +| `present` | a prose marker (`do not`, `instead`, `rather than`, `not for`) or an arrow clause was found | nothing | +| `absent` | neither was found | SUGGESTION: add a boundary clause, in either form | +| `unparsed` | an arrow clause was found and **no target could be read out of it** | SUGGESTION: the clause is present — this is a *parse* failure, not a missing clause | + +The third had to be split out. Collapsing it into `absent` is a **wrong** finding, not a strict one: +it sends the author to add a clause that is already there. Three of them instead reworded a correct +clause until the regex accepted it, one stripping the very filename that discriminates the skill +from its neighbour (**#110**). + +`unparsed` is narrow and certain on purpose. It fires only on the arrow form, which *always* names a +target, so zero targets means the name is written in a shape the extractor cannot see — in practice +a bare single-word target, per the known gap above, and the message says to write it `` `name` `` or +`/name`. A **prose** clause yielding no target is not reported at all: "Do not use for anything else" +is a complete and legitimate boundary clause that names nowhere to go. + +**One arrow, one target.** An arrow clause naming two or more targets draws its own SUGGESTION, +quoting both names and asking for a split, because only the first is ever resolved: the conjunction +continuation (`CONT_MARKED` / `CONT_ANY`) is wired to the prose route verbs and never to arrows. So +`Not X -> a or b` resolved `a`, left `b` resolved by nothing and reported by nothing, and then let +the audit print "1 of 1 boundary target(s) resolve" on a clause naming two — a gate under-reporting +its own coverage, which is the one failure mode ADR-0020 says a gate must not have (**#107**). The +clause is **rejected rather than the arrow scan extended**: extending it would widen the resolver's +deliberately conservative false-positive tuning across every arrow in the corpus, where splitting +costs the author one full stop. The convention is one arrow per target — `Not X -> a. Not Y -> b.` — +already what every retrofitted `gitea-*` skill does in practice, now stated in +`skill-author`'s `references/contract.md` instead of being folklore. + +**Dotted filenames in a boundary clause now parse.** `CLAUSE_BODY` — what may sit between `Not` and +the arrow — used to be `[^.;]`, a class that cannot cross a `.`, so every clause naming a dotted +filename between the two (`AGENTS.md`, `.vale.ini`, `.pre-commit-config.yaml`) was invisible to both +`BOUNDARY_ARROW` and `ARROW_BOUNDARY`. The two resulting failures were different sizes (**#110**): + +- with a **backticked** target the clause was *misdiagnosed*. The backtick sweep still extracted the + target, so the route was checked, but the gate reported "no boundary clause" on a clause that was + present and working. That is the misdiagnosis the three rewordings above came from. +- with a **bare** target the clause was *unchecked*. `ARROW_BOUNDARY` is the only extractor for a + bare arrow target, so `Not AGENTS.md -> no-such-skill` produced no target, no dangling report and + no missing-clause SUGGESTION. Silence, not noise — the worse of the two. + +`CLAUSE_BODY` is now `(?:[^.;]|\.(?=\S))`: a dot inside a filename is followed by a non-space, a +sentence-ending dot by whitespace or end of string, so the class crosses `AGENTS.md` and still stops +at a real sentence end. **Read the second bullet forward as well as back:** a bare target sitting +after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the +same clause used to pass unchecked in silence. ### SUGGESTION-only checks -Three more, deterministic to measure but judgment to act on: +Deterministic to measure, judgment to act on: -- a description with **no boundary clause at all**; +- a description with **no boundary clause at all** (`absent`); +- an **arrow clause whose target could not be read** (`unparsed`); +- an **arrow clause naming more than one target**; - a `## Gotchas` section with **more than five entries**; - a `## Gotchas` section over **25% of the body**. +### Hand-invoked skills are exempt from the routing rules, and only those + +A skill or agent whose frontmatter carries `disable-model-invocation: true` skips three checks: + +- the boundary-clause check, `absent` and `unparsed` alike; +- the multi-target arrow check; +- the 250-character description **target** (`hand_invoked()` in `scripts/skill-size-check.sh`). + +It keeps the 400-character description FAIL and **both** body word tiers, and if its description +does happen to name a target, that target is still resolved and can still dangle. + +Why the exemption is right: `disable-model-invocation: true` removes the skill from the +model-visible listing entirely — it is not preloaded, and the Skill tool refuses to call it — so its +description is never matched against user intent. ADR-0020 and `skill-author`'s contract therefore +give such a skill **one plain human-facing sentence**: no trigger list, no boundary clause. No +validator knew the field existed (**#108**), so the boundary-clause SUGGESTION fired on exactly the +shape the contract mandates, and its remedy — "so the router knows where NOT to send this skill" — +was addressed to a router that cannot see the skill at all. An author who followed the advice made +the file worse. There is no router to inform. + +The half that does **not** lift is the point. The body is still loaded on invocation and still +competes with the caller's live conversation, so neither body tier moves. The 400-character ceiling +stands too: a hand-invoked description is not preloaded, but it is still the one line the user reads +when choosing from the `/` menu, and that ceiling is an outlier stop rather than a routing-quality +budget — which is precisely why the 250-character target is the tier that lifts. + +The field is read as a **boolean**, not as a mention of the key. PyYAML already resolves the +unquoted YAML 1.1 booleans, so the extra handling catches a quoted `"true"`, which a host reads as +truthy; `disable-model-invocation: false` is the model-invoked case written out longhand and buys +nothing. A frontmatter parse failure returns false rather than raising — the flag is a *modifier* on +other checks, and `description_value()` on the same text already reports the broken frontmatter, so +raising here would diagnose one file twice two different ways. + +`caveman` and `zoom-out` are the two carriers here. `tests/test-skill-size-check.sh` pins both +halves — what the carve-out lifts, each with a flag-removed control, and what it must not. + ### `verbose: true` is load-bearing The hook is declared `verbose: true` so the SUGGESTION tier is audible. pre-commit prints nothing at @@ -281,8 +384,10 @@ script.** `scripts/skill-size-check.sh` applies its body gate to whatever path i bash scripts/skill-size-check.sh plugins/*/.apm/agents/*.agent.md ``` -exits 1 today with 900-word body FAILs on `git-orchestrate` (933), `gitea-orchestrate` (1,199) and -`apm-orchestrate` (1,113). Agent files escape only because the hook definitions filter on `SKILL.md` +exits 1 today with 900-word body FAILs on `git-orchestrate` and `gitea-orchestrate`. (Counts are +deliberately not pinned here — agent bodies are edited like any other file, and a figure in this +paragraph goes stale the moment one is trimmed. Run the command.) Agent files escape only because +the hook definitions filter on `SKILL.md` — a file-pattern accident that happens to implement the design, not the design itself. **Do not "extend" that hook's `files:` pattern to cover agents** on the assumption that the script already knows the difference; doing so silently enforces a gate ADR-0020 declines to set. @@ -293,13 +398,30 @@ knows the difference; doing so silently enforces a gate ADR-0020 declines to set non-compliant skill's current numbers was considered and rejected in favour of hot gates. **The corpus is now clean on both gates.** Issue **#99** retrofitted all 39 skills plugin by plugin; -`kyberforge` was the last wave, followed by two corpus-wide passes. +`kyberforge` was the last wave, after which the corpus was swept as a whole rather than per plugin. +Each sweep is followed by an **independent review round**: a fresh agent with no memory of the +retrofit re-measures the corpus and files what it finds, and the round repeats until one lands no +findings. The rounds are recorded as comments on **#99** — read the current state off that thread, +which is why no round count is pinned here. | Gate | Current findings | |---|---| -| `skill-size-check` | **0 of 39** descriptions and **0 of 39** bodies exceed their FAIL tier; 0 dangling targets; 31 SUGGESTIONs | +| `skill-size-check` | **0 of 39** descriptions and **0 of 39** bodies exceed their FAIL tier; 0 dangling targets; SUGGESTIONs outstanding (count not pinned — see below) | | `Kyberforge.CompositionNote` (Vale) | **0 errors** — the four `gitea-*` carriers were all retrofitted | +**The SUGGESTION count is deliberately not recorded here.** It moves with every skill edit *and* +with every change to the gate's own tiering, so any figure written down is stale by the next commit. +Measure it instead: + +``` +bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md | grep -c '^SUGGESTION' +pre-commit run skill-size-check --all-files # same findings, via the hook +``` + +A non-zero count is the expected steady state, not a regression. SUGGESTIONs exit 0 and block +nothing; only the two FAIL tiers, the dangling-target ERROR and the missing-`references/` ERROR do. +Read the count as a work queue, and the FAIL columns above as the gate. + `Kyberforge.CompositionNote` is the ADR-0020 Vale rule banning composition and architecture prose from a description. Every Vale rule here is `level: error` with no ignorable tier, so a description that reintroduces one blocks the commit even though no skill carries one today. -- 2.43.0 From 8599b4a05856804dfd04ef97d69e4fa0af28955e Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:02:49 +0000 Subject: [PATCH 70/89] chore(release): bump the marketplace version to 0.5.0 0959291 bumped all six plugin versions for the ADR-0020 retrofit but left the marketplace itself at 0.4.5, so consumers resolving against the marketplace entry would see no change despite every packaged plugin having moved. --- .claude-plugin/marketplace.json | 2 +- .github/plugin/marketplace.json | 2 +- apm.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db75088..f4c1ab1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.4.5", + "version": "0.5.0", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index db75088..f4c1ab1 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.4.5", + "version": "0.5.0", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", diff --git a/apm.yml b/apm.yml index bd69ff0..5d3ac05 100644 --- a/apm.yml +++ b/apm.yml @@ -1,5 +1,5 @@ name: holocron -version: 0.4.5 +version: 0.5.0 description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. license: MIT @@ -52,7 +52,7 @@ marketplace: # top-level apm.yml description:/version: above are NOT inherited into the # compiled output despite being used elsewhere (e.g. by `apm audit`). description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. - version: 0.4.5 + version: 0.5.0 owner: name: Defame1297 email: defame1297@rkdr.net -- 2.43.0 From 5b80f305e99338d9471d517f2d89147bdad58d8c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 08:28:25 +0000 Subject: [PATCH 71/89] test(kyberforge): pin the provenance checker's three new behaviours 27a7669 changed validate-provenance.sh in three ways and tested none of them: the bullet-form parser returning None rather than [] on unparsable input, the source_keys: [] house-authored declaration, and the stripping of section annotations off a Research doc path. All three are checks that previously failed by staying silent, which is the failure mode this repo keeps rediscovering. Shipping them untested left nothing to catch a regression back to silence. The 22 cases assert consequences rather than return values -- that check 8 runs on an unparsable block, that an unresolvable path emits an INFO naming its slug -- and each was verified against a deliberate mutation of the behaviour it covers. Addresses #111. --- .../tests/validate-provenance.bats | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats index 58dc65e..7b0df0c 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats @@ -64,6 +64,43 @@ EOF - **Contributing files:** ${contrib} - **Research doc:** ${research} - **Status:** \`extracted\` +EOF + } + + # Helper: create a fake repo (a .git marker makes find_repo_root stop there) + # holding one skill whose single sources.md slug points at the given + # Research doc value. Checks 7 and 8 only run for a skill inside a checkout, + # so every upstream case needs this shape; the research doc itself is + # written per test into "$repo/docs/research/my-research.md". + make_upstream_skill() { + local repo="$1" + local research="${2:-docs/research/my-research.md}" + local skill="$repo/my-skill" + mkdir -p "$skill/references" "$repo/docs/research" + touch "$repo/.git" + cat > "$skill/SKILL.md" <<EOF +--- +name: my-skill +description: A valid skill description. +metadata: + source_keys: + - my-source +--- + +## Step 1 + +Do the thing. +EOF + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** A test source. +- **Contributing files:** SKILL.md +- **Research doc:** ${research} +- **Status:** \`extracted\` EOF } } @@ -512,3 +549,439 @@ EOF run bash "$SCRIPT" "$skill" assert_success } + +# --------------------------------------------------------------------------- +# Cycle 13 — parse_contributing_files: None ("could not parse") is NOT [] +# ("explicitly (none)") +# +# Check 8 reads [] as "the research doc deliberately records no contributing +# files" and SKIPS the slug on that basis. A block the parser cannot read must +# therefore return None, so the slug stays exposed to check 8. Each case below +# asserts that CONSEQUENCE — check 8 firing on the unreadable entry — not the +# parser's return value, because returning [] is exactly the shape that makes +# the check silently do nothing while still exiting 0. +# --------------------------------------------------------------------------- + +@test "check 8 runs: bullet form with '*' asterisk bullets is unparsable, not '(none)'" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +**Contributing files:** +* some-skill/references/extra.md + +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "extra-source" +} + +@test "check 8 runs: bullet form with a numbered list is unparsable, not '(none)'" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +**Contributing files:** +1. some-skill/references/extra.md + +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "extra-source" +} + +@test "check 8 runs: bullet form followed by prose is unparsable, not '(none)'" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +**Contributing files:** +See the table below for the file list. + +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "extra-source" +} + +@test "check 8 runs: bullet form heading with a blank line and nothing after is unparsable, not '(none)'" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Status:** \`extracted\` + +**Contributing files:** + +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "extra-source" +} + +@test "check 8 runs: inline form whose whole value is a parenthetical is unparsable, not '(none)'" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** (see notes below) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "extra-source" +} + +@test "check 4 runs: bullet form with '-' hyphen bullets still parses each path" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** A test source. + +**Contributing files:** +- SKILL.md (the dispatch table, with a comma in the note) +- references/nonexistent.md (why this one matters) + +- **Research doc:** (none) +- **Status:** \`extracted\` +EOF + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "Contributing file 'references/nonexistent.md' does not exist" +} + +@test "check 4 runs: inline comma-separated form still parses each path" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" "my-source" "SKILL.md, references/nonexistent.md" + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "Contributing file 'references/nonexistent.md' does not exist" +} + +@test "check 8 skips: bullet form '- (none)' is an explicit declaration" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +**Contributing files:** +- (none — nothing was extracted from this section) + +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success +} + +@test "check 8 skips: inline bare '(none)' is an explicit declaration" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success +} + +# --------------------------------------------------------------------------- +# Cycle 14 — Check 3 (#111): an explicit 'source_keys: []' is a house-authored +# declaration, a bare 'source_keys:' is truncation +# +# Three states, three outcomes. Only the middle one — declared empty — is +# silent; collapsing any pair of them is the defect #111 filed. +# --------------------------------------------------------------------------- + +@test "check 3 silent: references doc with top-level 'source_keys: []' emits nothing" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +source_keys: [] +--- + +# Extra Reference + +House-authored, no external source. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output "" +} + +@test "check 3 silent: references doc with 'source_keys: []' nested under metadata: emits nothing" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +metadata: + source_keys: [] +--- + +# Extra Reference + +House-authored, no external source. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output "" +} + +@test "INFO: references doc with a bare 'source_keys:' and no value still emits INFO" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +source_keys: +--- + +# Extra Reference + +Truncated frontmatter — this is not a decision. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "No source_keys frontmatter" +} + +@test "INFO: references doc with frontmatter but no source_keys key still emits INFO" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +title: Extra Reference +--- + +# Extra Reference + +Never said either way. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "No source_keys frontmatter" +} + +# --------------------------------------------------------------------------- +# Cycle 15 — Checks 7 and 8: Research doc annotation stripping, and the INFO +# that replaced the silent skip +# +# A Research doc value is very often a path PLUS a section annotation, and +# os.path.isfile() is false for every such string. Before the strip, checks 7 +# and 8 skipped SILENTLY on those — so the negative assertions here (an INFO +# that must appear) are what distinguishes a running check from a dead one. +# --------------------------------------------------------------------------- + +@test "check 7 runs: '§' section annotation is stripped before the path is resolved" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" 'docs/research/my-research.md § "Some Section"' + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## different-slug + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + refute_output --partial "§" +} + +@test "check 7 runs: '→' section annotation is stripped before the path is resolved" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" 'docs/research/my-research.md → `## Pushing`' + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## different-slug + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + refute_output --partial "→" +} + +@test "check 7 runs: parenthetical annotation is stripped before the path is resolved" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" "docs/research/my-research.md (whole-document reference)" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## different-slug + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + refute_output --partial "whole-document reference" +} + +@test "check 7 runs: a bare path with no annotation survives the strip intact" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" "docs/research/my-research.md" + + cat > "$fake_repo/docs/research/my-research.md" <<EOF +# Research + +## my-source + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output "" +} + +@test "checks 7 and 8 skipped silently: Research doc '(none)' is recognised before the strip" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" "(none)" + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output "" +} + +@test "checks 7 and 8 skipped silently: bare 'none — reason' is recognised as a declaration" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" "none — org convention, no upstream research doc" + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output "" +} + +@test "INFO: a stripped path that does not resolve names the slug instead of skipping silently" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" 'docs/research/missing.md § "Some Section"' + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "Upstream checks skipped for 'my-source' — research doc 'docs/research/missing.md' does not exist" +} + +@test "INFO: a Research doc value that is entirely annotation names the slug instead of skipping silently" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" '§ "Some Section"' + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "Upstream checks skipped for 'my-source' — Research doc value names no path" +} + +@test "INFO: no repo root above the skill directory names the slug instead of skipping silently" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" "my-source" "SKILL.md" "docs/research/my-research.md" + + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "Upstream checks skipped for 'my-source' — no repo root above the skill directory" +} -- 2.43.0 From c232e69645fd64095be5435337cbfdd954210290 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:45:45 +0000 Subject: [PATCH 72/89] fix(gates): block a dangling /name route with no preceding verb The header promised explicit route notation always blocks. It did not: /name reached extraction only behind a ROUTE_VERB, so a target with no verb before it was never extracted at all -- exit 0, no output. Taking the SUGGESTION's own advice ('write it as /name and it will be checked properly') was the one edit that blinded the gate. Adds two notation sweeps gated on BOUNDARY_MARKER and routed through _add, plus a path guard so file paths and URLs are not read as routes. Also excises the matched pointer span before the REFERENCE_PAST sweep, so a reference file can no longer exempt itself by its own filename, and guards the agent branch with the isfile test the skills branch already had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- .../skills/agent-audit/scripts/validate.sh | 75 ++++++++++++++++++- .../skills/skill-audit/scripts/validate.sh | 75 ++++++++++++++++++- .../skills/agent-audit/scripts/validate.sh | 75 ++++++++++++++++++- .../skills/skill-audit/scripts/validate.sh | 75 ++++++++++++++++++- scripts/skill-size-check.sh | 75 ++++++++++++++++++- 5 files changed, 370 insertions(+), 5 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index c24f739..46319f4 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names): 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')] @@ -542,6 +552,34 @@ 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 inside a boundary sentence, 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. +# 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 sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate +# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# # 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. @@ -565,6 +603,23 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their +# caller. 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 the +# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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)\b(?!/|\.\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`, @@ -740,6 +795,16 @@ def _extract_sentence(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: + # Route notation wherever it sits in the clause, not only where a route + # verb or an arrow happens to precede it. See the EXPLICIT ROUTE + # NOTATION note in the header for the seven phrasings this recovers and + # for why silence was the failure mode. Neither sweep takes the follower + # test: _add() reads the notation first and both forms reach it marked. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + 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 @@ -1110,7 +1175,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 diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index a871fe2..8934687 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names): 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')] @@ -468,6 +478,34 @@ 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 inside a boundary sentence, 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. +# 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 sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate +# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# # 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. @@ -491,6 +529,23 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their +# caller. 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 the +# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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)\b(?!/|\.\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`, @@ -666,6 +721,16 @@ def _extract_sentence(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: + # Route notation wherever it sits in the clause, not only where a route + # verb or an arrow happens to precede it. See the EXPLICIT ROUTE + # NOTATION note in the header for the seven phrasings this recovers and + # for why silence was the failure mode. Neither sweep takes the follower + # test: _add() reads the notation first and both forms reach it marked. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + 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 @@ -1036,7 +1101,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 diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh index c24f739..46319f4 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh @@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names): 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')] @@ -542,6 +552,34 @@ 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 inside a boundary sentence, 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. +# 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 sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate +# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# # 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. @@ -565,6 +603,23 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their +# caller. 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 the +# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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)\b(?!/|\.\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`, @@ -740,6 +795,16 @@ def _extract_sentence(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: + # Route notation wherever it sits in the clause, not only where a route + # verb or an arrow happens to precede it. See the EXPLICIT ROUTE + # NOTATION note in the header for the seven phrasings this recovers and + # for why silence was the failure mode. Neither sweep takes the follower + # test: _add() reads the notation first and both forms reach it marked. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + 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 @@ -1110,7 +1175,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 diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh index a871fe2..8934687 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh @@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names): 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')] @@ -468,6 +478,34 @@ 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 inside a boundary sentence, 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. +# 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 sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate +# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# # 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. @@ -491,6 +529,23 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their +# caller. 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 the +# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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)\b(?!/|\.\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`, @@ -666,6 +721,16 @@ def _extract_sentence(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: + # Route notation wherever it sits in the clause, not only where a route + # verb or an arrow happens to precede it. See the EXPLICIT ROUTE + # NOTATION note in the header for the seven phrasings this recovers and + # for why silence was the failure mode. Neither sweep takes the follower + # test: _add() reads the notation first and both forms reach it marked. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + 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 @@ -1036,7 +1101,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 diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index cf15c71..619b9ed 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -292,6 +292,16 @@ def _collect_package(pkg_dir, names): 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')] @@ -570,6 +580,34 @@ 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 inside a boundary sentence, 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. +# 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 sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate +# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# # 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. @@ -593,6 +631,23 @@ 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) +# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their +# caller. 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 the +# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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)\b(?!/|\.\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`, @@ -768,6 +823,16 @@ def _extract_sentence(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: + # Route notation wherever it sits in the clause, not only where a route + # verb or an arrow happens to precede it. See the EXPLICIT ROUTE + # NOTATION note in the header for the seven phrasings this recovers and + # for why silence was the failure mode. Neither sweep takes the follower + # test: _add() reads the notation first and both forms reach it marked. + for match in NOTATION_SLASH.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + 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 @@ -1138,7 +1203,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 -- 2.43.0 From 00daf285ec3b76ed8bb83454b6ee67513b770187 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:46:05 +0000 Subject: [PATCH 73/89] fix(kyberforge): tell an unparsable Contributing-files block from an explicit (none) parse_contributing_files documented that callers depend on None vs [], because a parse failure returning [] would silently disable the check. Only check 8 honoured it; checks 4/5 (skill-audit) and 3/4 (agent-audit) used a truthiness test, so an unreadable block disabled them without a word. Two live corpus entries were skipping this way. A sweep of all 32 sources.md found 134 entries, exactly 2 parsing to None, both in gitea-files: one heading carried an inline parenthetical that defeated both regexes, and one (none) was written without its leading bullet. Also pins EMPTY_SOURCE_KEYS_RE to the two indents parse_source_keys actually reads. agent-audit had no INFO tier at all, so it gains one rather than reporting a check that could not run as a FAIL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- .../skills/gitea-files/references/sources.md | 6 +- .../skills/gitea-files/references/sources.md | 6 +- .../scripts/validate-provenance.sh | 53 +++++-- .../tests/validate-provenance.bats | 137 ++++++++++++++++++ .../scripts/validate-provenance.sh | 33 ++++- .../tests/validate-provenance.bats | 129 +++++++++++++++++ .../scripts/validate-provenance.sh | 53 +++++-- .../scripts/validate-provenance.sh | 33 ++++- 8 files changed, 418 insertions(+), 32 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-files/references/sources.md b/plugins/gitea/.apm/skills/gitea-files/references/sources.md index 756778d..9c94072 100644 --- a/plugins/gitea/.apm/skills/gitea-files/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-files/references/sources.md @@ -2,13 +2,13 @@ ## gitea-mcp-repo -**Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. +**Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. Tool parameters and SHA/concurrency behavior were cross-checked live against the deployed MCP tool schemas via `ToolSearch`, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. **Source:** https://gitea.com/gitea/gitea-mcp - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** (tool parameters and SHA/concurrency behavior cross-checked live against the deployed MCP tool schemas via ToolSearch) +**Contributing files:** - SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch) - references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) - references/writing.md (write-tool parameters, SHA/concurrency behavior, canonical call sequences, failed-write triage) @@ -45,4 +45,4 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** (none) +- **Contributing files:** (none) diff --git a/plugins/gitea/skills/gitea-files/references/sources.md b/plugins/gitea/skills/gitea-files/references/sources.md index 756778d..9c94072 100644 --- a/plugins/gitea/skills/gitea-files/references/sources.md +++ b/plugins/gitea/skills/gitea-files/references/sources.md @@ -2,13 +2,13 @@ ## gitea-mcp-repo -**Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. +**Description:** Official gitea-mcp repository (v1.3.0); operation/*.go source files documenting all 55 MCP tools, their parameters, and CLI flags. Tool parameters and SHA/concurrency behavior were cross-checked live against the deployed MCP tool schemas via `ToolSearch`, per this repo's process for resolving schema-vs-docs drift, rather than copied from the derived research doc. **Source:** https://gitea.com/gitea/gitea-mcp - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** (tool parameters and SHA/concurrency behavior cross-checked live against the deployed MCP tool schemas via ToolSearch) +**Contributing files:** - SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch) - references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) - references/writing.md (write-tool parameters, SHA/concurrency behavior, canonical call sequences, failed-write triage) @@ -45,4 +45,4 @@ - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -**Contributing files:** (none) +- **Contributing files:** (none) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh index a5525d1..4b07ae3 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh @@ -22,7 +22,10 @@ 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 EOF @@ -244,14 +247,31 @@ has_fail = False def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix)) + findings.append(("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): + findings.append(("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() # --- Collect source_keys from agent pair --- def get_source_keys_from_file(fpath): @@ -321,9 +341,24 @@ 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) + # 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: + 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): diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats index d14635c..186d484 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats @@ -433,3 +433,140 @@ 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:" +} diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh index 5524605..2e8cca9 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh @@ -21,7 +21,10 @@ Checks performed: 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 + 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). A section @@ -104,7 +107,13 @@ def parse_source_keys(fm): # 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. -EMPTY_SOURCE_KEYS_RE = re.compile(r'^\s*source_keys:\s*\[\s*\]\s*$') +# +# 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: []`.""" @@ -436,9 +445,25 @@ repo_root = find_repo_root(skill_dir) research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it for slug in parse_h2_slugs(sources_content): - # Check 4: Contributing files exist + # 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: + 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): diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats index 7b0df0c..aef8d83 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats @@ -985,3 +985,132 @@ EOF assert_output --partial "INFO" assert_output --partial "Upstream checks skipped for 'my-source' — no repo root above the skill directory" } + +# --------------------------------------------------------------------------- +# Cycle 16 — Checks 4 and 5: None ("could not parse") is NOT [] ("explicitly +# (none)"), on the sources.md side this time +# +# Cycle 13 pinned the distinction for check 8, which reads the parser's output +# against a RESEARCH doc. Checks 4 and 5 read it against the skill's own +# sources.md and honoured neither half: a truthiness test collapsed None into +# [], so an unreadable Contributing files block disabled both checks and the +# script still exited 0 with no output — the failure mode +# parse_contributing_files' docstring names in as many words. 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 4 and 5 silently" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** A test source. +- **Research doc:** (none) +**Contributing files:** +* references/ghost.md (asterisk bullets are not the bullet form) +- **Status:** \`extracted\` +EOF + run bash "$SCRIPT" "$skill" + 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 skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/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" "$skill" + 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 4 and 5 skipped silently: an explicit '(none)' emits no INFO" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" "my-source" "(none — not used directly)" + run bash "$SCRIPT" "$skill" + assert_success + assert_output "" +} + +@test "checks 4 and 5 still run: a parseable Contributing files list is not diverted to the INFO" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" "my-source" "references/nonexistent.md" + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "FAIL" + assert_output --partial "Contributing file 'references/nonexistent.md' does not exist" + refute_output --partial "could not be parsed" +} + +# --------------------------------------------------------------------------- +# Cycle 17 — Check 3 (#111): 'source_keys: []' only declares anything in the +# two positions parse_source_keys() actually reads +# +# The declaration and the parse have to agree on WHERE the key lives. They did +# not: the declaration regex accepted any indent, so a `source_keys: []` buried +# under an unrelated key — a position parse_source_keys() never reads — passed +# as a house-authored declaration and silenced the INFO for a file that had +# declared nothing. +# --------------------------------------------------------------------------- + +@test "INFO: 'source_keys: []' nested under an unrelated key is not a declaration" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +title: Extra Reference +unrelated: + nested: + source_keys: [] +--- + +# Extra Reference + +The empty list is nested where parse_source_keys never looks. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "No source_keys frontmatter" +} + +@test "INFO: a four-space-indented 'source_keys: []' is not a declaration either" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/extra.md" <<EOF +--- +metadata: + source_keys: [] +--- + +# Extra Reference + +Two spaces is the position parse_source_keys reads under metadata:, not four. +EOF + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "INFO" + assert_output --partial "No source_keys frontmatter" +} diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh index a5525d1..4b07ae3 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh @@ -22,7 +22,10 @@ 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 EOF @@ -244,14 +247,31 @@ has_fail = False def emit_fail(desc, fpath, why, fix): global has_fail has_fail = True - findings.append(("FAIL", desc, fpath, why, fix)) + findings.append(("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): + findings.append(("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() # --- Collect source_keys from agent pair --- def get_source_keys_from_file(fpath): @@ -321,9 +341,24 @@ 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) + # 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: + 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): diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh index 5524605..2e8cca9 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh @@ -21,7 +21,10 @@ Checks performed: 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 + 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). A section @@ -104,7 +107,13 @@ def parse_source_keys(fm): # 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. -EMPTY_SOURCE_KEYS_RE = re.compile(r'^\s*source_keys:\s*\[\s*\]\s*$') +# +# 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: []`.""" @@ -436,9 +445,25 @@ repo_root = find_repo_root(skill_dir) research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it for slug in parse_h2_slugs(sources_content): - # Check 4: Contributing files exist + # 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: + 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): -- 2.43.0 From a8cd5e881df81d0a7b514b5d466ccdc93e783093 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:46:07 +0000 Subject: [PATCH 74/89] fix(core): strip a BOM before the adapter import check, and split usage exits Narrowing has_reference to bool(import_lines) meant a UTF-8 BOM hid the import line, since the BOM is not \s: a CLAUDE.md whose first line is @AGENTS.md failed with 'no reference to AGENTS.md' and was told to add the line already in front of it. Decoding is now strict too, so a non-UTF-8 adapter gets an encoding diagnostic instead of being mangled and then graded on the mangling. Usage errors move to exit 2. They shared exit 1 with real findings, while the skill tells the agent to fix any non-zero exit by editing the provider file. The whole-line import rule is kept deliberately -- accepting an inline @AGENTS.md would also accept one inside backticks, which is the silent-drop failure the validator exists to catch -- and the message now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- .../skills/provider-adapter-author/README.md | 2 +- .../skills/provider-adapter-author/SKILL.md | 6 +- .../references/provider-matrix.md | 9 +++ .../scripts/validate-adapter.sh | 56 +++++++++++++++---- .../tests/validate-adapter.bats | 47 +++++++++++++++- .../skills/provider-adapter-author/README.md | 2 +- .../skills/provider-adapter-author/SKILL.md | 6 +- .../references/provider-matrix.md | 9 +++ .../scripts/validate-adapter.sh | 56 +++++++++++++++---- 9 files changed, 162 insertions(+), 31 deletions(-) diff --git a/plugins/core/.apm/skills/provider-adapter-author/README.md b/plugins/core/.apm/skills/provider-adapter-author/README.md index 9401197..1282b6c 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/README.md @@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 76111c7..97f7e16 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -18,6 +18,8 @@ metadata: - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). + ## Step 1 — Detect Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. @@ -28,7 +30,7 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -43,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md index 2d684cf..b747538 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md @@ -21,3 +21,12 @@ silently drops every rule the adapter was supposed to defer to. Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. There is no handshake to rely on and no state passed +in beyond the file paths. diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh index 513ef1f..66ba72c 100755 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -31,6 +31,12 @@ Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad or missing argument, a path that is not a + file, or a file that is not UTF-8. Nothing was graded, so there is no + FAIL line and no adapter edit to make: fix the invocation or the file's + encoding and re-run. Kept distinct from 1 because the skill's own + closeout tells the agent to fix every non-zero exit by editing the + provider file, which for a mistyped flag edits the wrong file forever. EOF } @@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 - exit 1 + exit 2 fi MAX_LINES="$2" if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 - exit 1 + exit 2 fi shift 2 ;; @@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -85,15 +91,43 @@ max_lines = int(max_lines) if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is + not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had + its first line — the `@AGENTS.md` import, which is the whole adapter — + silently treated as prose. The check then said "no reference to AGENTS.md" + told the author to add the line already sitting in front of them. Same + class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it + at the reader so no later check has to know about it. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + """ + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(2) + return text[1:] if text.startswith("\ufeff") else text + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) has_fail = False @@ -124,8 +158,8 @@ if not has_reference: print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check --- diff --git a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats index e1277aa..9d18f1e 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats +++ b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats @@ -148,7 +148,7 @@ EOF ADAPTER="$TMPDIR/CLAUDE.md" echo "@AGENTS.md" > "$ADAPTER" run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" --max-lines - assert_failure + assert_failure 2 assert_output --partial "--max-lines requires a value" } @@ -156,7 +156,7 @@ EOF ADAPTER="$TMPDIR/CLAUDE.md" echo "@AGENTS.md" > "$ADAPTER" run bash "$SCRIPT" --max-lines abc "$ADAPTER" "$AGENTS_MD" - assert_failure + assert_failure 2 assert_output --partial "non-negative integer" } @@ -168,6 +168,47 @@ EOF @test "fails with a clear error when the adapter file argument is missing" { run bash "$SCRIPT" - assert_failure + assert_failure 2 assert_output --partial "required" } + +@test "a UTF-8 BOM before the @import line does not hide it" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'w', encoding='utf-8-sig').write('@AGENTS.md\n')" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "a usage error exits 2, a genuine finding exits 1" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + + run bash "$SCRIPT" --max-lines -3 "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + refute_output --partial "FAIL" + + : > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "FAIL" +} + +@test "a non-UTF-8 adapter is reported as an encoding error, not as a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-16'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "an @AGENTS.md folded into a sentence fails, and the message says the import needs its own line" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +See @AGENTS.md for shared conventions. +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "line of its own" +} diff --git a/plugins/core/skills/provider-adapter-author/README.md b/plugins/core/skills/provider-adapter-author/README.md index 9401197..1282b6c 100644 --- a/plugins/core/skills/provider-adapter-author/README.md +++ b/plugins/core/skills/provider-adapter-author/README.md @@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 76111c7..97f7e16 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -18,6 +18,8 @@ metadata: - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). + ## Step 1 — Detect Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. @@ -28,7 +30,7 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -43,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md index 2d684cf..b747538 100644 --- a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md @@ -21,3 +21,12 @@ silently drops every rule the adapter was supposed to defer to. Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. There is no handshake to rely on and no state passed +in beyond the file paths. diff --git a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh index 513ef1f..66ba72c 100755 --- a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -31,6 +31,12 @@ Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad or missing argument, a path that is not a + file, or a file that is not UTF-8. Nothing was graded, so there is no + FAIL line and no adapter edit to make: fix the invocation or the file's + encoding and re-run. Kept distinct from 1 because the skill's own + closeout tells the agent to fix every non-zero exit by editing the + provider file, which for a mistyped flag edits the wrong file forever. EOF } @@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 - exit 1 + exit 2 fi MAX_LINES="$2" if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 - exit 1 + exit 2 fi shift 2 ;; @@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -85,15 +91,43 @@ max_lines = int(max_lines) if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is + not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had + its first line — the `@AGENTS.md` import, which is the whole adapter — + silently treated as prose. The check then said "no reference to AGENTS.md" + told the author to add the line already sitting in front of them. Same + class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it + at the reader so no later check has to know about it. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + """ + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(2) + return text[1:] if text.startswith("\ufeff") else text + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) has_fail = False @@ -124,8 +158,8 @@ if not has_reference: print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check --- -- 2.43.0 From 1a971ee003a671261782859874b8e99dbfb0466a Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:46:32 +0000 Subject: [PATCH 75/89] fix(gitea): restore routing and sourcing content the retrofit dropped gitea-issues asserted flatly that a merge never closes an issue, contradicting gitea-prs' references/merging.md, which documents that closing keywords in commits landing on the default branch do close one. The qualifier that made the claim true had been deleted; both sides now agree. gitea-releases had lost an epistemic hedge and its verification step, leaving conventions.md asserting unconfirmed tag auto-creation as fact. Nothing in the research corpus sources it, so the hedge and the verify-afterward instruction are back rather than upgraded. Descriptions were cut 50-240 chars under the 400 budget and shed routing with them: gitea-workflow's boundary named no target, gitea-prs lost the issue/PR number-space directive the suite is built around at 163/400, gitea-releases lost its boundary and every trigger. Restored, inside budget. Also restores delete_branch's hard-refusal strength and gitea-files' Read/Write/Edit pointer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- .../gitea/.apm/skills/gitea-branches/SKILL.md | 2 +- .../gitea/.apm/skills/gitea-files/SKILL.md | 4 ++-- .../gitea/.apm/skills/gitea-issues/SKILL.md | 13 +++++++------ .../skills/gitea-labels-milestones/SKILL.md | 5 +++-- plugins/gitea/.apm/skills/gitea-prs/SKILL.md | 4 +++- .../gitea/.apm/skills/gitea-releases/SKILL.md | 12 +++++++----- .../gitea-releases/references/conventions.md | 19 ++++++++++++++----- .../gitea/.apm/skills/gitea-workflow/SKILL.md | 3 ++- plugins/gitea/skills/gitea-branches/SKILL.md | 2 +- plugins/gitea/skills/gitea-files/SKILL.md | 4 ++-- plugins/gitea/skills/gitea-issues/SKILL.md | 13 +++++++------ .../skills/gitea-labels-milestones/SKILL.md | 5 +++-- plugins/gitea/skills/gitea-prs/SKILL.md | 4 +++- plugins/gitea/skills/gitea-releases/SKILL.md | 12 +++++++----- .../gitea-releases/references/conventions.md | 19 ++++++++++++++----- plugins/gitea/skills/gitea-workflow/SKILL.md | 3 ++- 16 files changed, 78 insertions(+), 46 deletions(-) diff --git a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md index 96af538..4645153 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git - **404 often means 403.** Gitea masks permission errors as not-found; on an unexpected one, check token scope before reporting a branch or commit missing. - **Nothing auto-paginates.** `list_branches` and `list_commits` return one page; iterate `page` until the returned count is below `per_page`. -- **`delete_branch` has no force-push guard.** Check `protected` from `list_branches` first and require explicit confirmation — a protected branch need not be named `main`. +- **`delete_branch` has no force-push guard.** Treat deleting a protected branch as a hard refusal unless the user explicitly confirms it in the conversation. Check `protected` from `list_branches` first — a protected branch need not be named `main`. ## Step 1 — Resolve owner and repo diff --git a/plugins/gitea/.apm/skills/gitea-files/SKILL.md b/plugins/gitea/.apm/skills/gitea-files/SKILL.md index 555782e..401952a 100644 --- a/plugins/gitea/.apm/skills/gitea-files/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-files/SKILL.md @@ -3,8 +3,8 @@ name: gitea-files description: > Use when reading or writing files or directories in a Gitea repository via the MCP server, - rather than the local filesystem — even when the user does not say "Gitea". Not commit - history -> `gitea-branches`. Not pull requests -> `gitea-prs`. + rather than the local filesystem (for a local path use Read/Write/Edit) — even when the user + does not say "Gitea". Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue diff --git a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md index 898805e..72754b5 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md @@ -2,8 +2,9 @@ name: gitea-issues description: > - Use when reading or writing Gitea issues — even when the user does not say "Gitea". - Not pull requests -> `gitea-prs`. + 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`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token @@ -25,14 +26,14 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`, never on a list item. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`. - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. -- **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. +- **A merge does not itself close the issue.** Gitea has no close-on-merge event, but a `Fixes #N` in the merged commits can, depending on merge style (`gitea-prs`). Re-read its state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. ## Step 1 — Resolve owner and repo -An orchestrating caller may pass `owner` and `repo` in already; if so, skip this. The `search` row is cross-repository and needs only a query, so it skips this too. Otherwise, before any tool call: +An orchestrating caller may pass `owner` and `repo` in already, and the `search` row is cross-repository and needs only a query — both skip this step. Otherwise, before any tool call: ```bash git remote get-url origin @@ -58,7 +59,7 @@ One invocation takes one row. Read only the reference(s) that row names — the ## Step 3 — Create -Only the create flow reaches this step; every other row goes straight to its reference. +Only the create flow reaches this step. 1. Take `title` and `body` from conversation context — the most recent task, bug report, or explicit statement. An empty body is an acceptable fallback, an invented one is not. 2. Run the enrichments in `references/enrichments.md`, then create with the resolved IDs per `references/issues.md`. Omitting a parameter always beats guessing its value — a wrong milestone or assignee is harder to notice than a missing one. diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md index 02d090e..e0605ab 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/SKILL.md @@ -2,8 +2,9 @@ name: gitea-labels-milestones description: > - Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer - labels — even when the user does not say "Gitea". + Use when reading or writing Gitea labels or milestones — "create a label", "what labels does + this repo have", "close the milestone" — or to resolve label names to IDs, or infer a + Kind/Priority/Status label from context, even when the user does not say "Gitea". Not applying them to an issue -> `gitea-issues`. Not to a PR -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. diff --git a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md index 236e009..1be7d5b 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-prs/SKILL.md @@ -3,7 +3,9 @@ name: gitea-prs description: > Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even - when the user does not say "Gitea". Not issues -> `gitea-issues`. + when the user does not say "Gitea". A number the user names may be an issue or a PR — they share + one number space — so confirm which domain applies before dispatching. Not issues -> + `gitea-issues`. Not branch or commit operations -> `gitea-branches`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when diff --git a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md index 1fbaa24..88a455b 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-releases/SKILL.md @@ -2,9 +2,11 @@ name: gitea-releases description: > - Use when managing Gitea releases or the git tags underneath them — list, get, - create, or delete either — even when the user does not say "release" or - "Gitea". Not branches or commit history -> `gitea-branches`. + Use when managing Gitea releases or the git tags underneath them — list, get, create, or + delete either — even when the user does not say "release" or "Gitea": "cut a v1.2.0", + "publish a prerelease", "tag this commit", "what's the latest release". Not branches or + commit history -> `gitea-branches`. Not issues -> `gitea-issues`. Not pull requests -> + `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope, which gates every release and tag tool here. Requires git remote "origin" pointing to the Gitea instance @@ -55,7 +57,7 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. -Pass a caller-supplied `tag_name` through verbatim — the API accepts any string, and semver with a `v` prefix is a tooling convention rather than a Gitea constraint. +Pass a caller-supplied `tag_name` through verbatim — the API accepts any string; semver is convention, not constraint. ## Step 3 — Procedure for the scenario in hand @@ -63,7 +65,7 @@ These four are mutually exclusive — pick the one row the request lands on. | Scenario | Procedure | |---|---| -| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release. | +| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release — whether `create_release` creates a missing tag is unconfirmed, so verify with `get_tag`. | | Delete a release | Resolve the numeric `id` per the first Gotcha, confirm intent, then call `delete_release`. The tag survives. | | Delete a tag along with its release | Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. | | List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. | diff --git a/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md b/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md index eabeeec..04c0059 100644 --- a/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md +++ b/plugins/gitea/.apm/skills/gitea-releases/references/conventions.md @@ -12,11 +12,20 @@ additional tool schemas. ## Release wraps a tag, not the reverse -A release is a title, body (notes), and draft/prerelease flags layered on top of an existing or -newly-created tag. The tag is the git-level object (a name pointing at a commit); the release is a -Gitea-level metadata wrapper around it. This is why `delete_release` and `delete_tag` are separate -calls with separate identifiers (numeric id vs. tag name) — removing the wrapper never implies -removing the underlying pointer, and vice versa. +A release is a title, body (notes), and draft/prerelease flags layered on top of a tag. The tag is +the git-level object (a name pointing at a commit); the release is a Gitea-level metadata wrapper +around it. This is why `delete_release` and `delete_tag` are separate calls with separate +identifiers (numeric id vs. tag name) — removing the wrapper never implies removing the underlying +pointer, and vice versa. + +### Whether `create_release` creates a missing tag is unconfirmed + +`create_release` takes both `tag_name` and `target` (a commitish), and that shape *suggests* Gitea +creates the tag at `target` when `tag_name` does not already exist. That is inferred from the API +shape, not confirmed by any source this skill was built from (`references/sources.md`) — so treat it +as an assumption, not behaviour. When the caller depends on the tag existing, verify it afterward +with `get_tag` (or `list_tags`) rather than reporting it as created. `create_tag` is the only call +confirmed to create one. ## Semver tag naming diff --git a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md index c92c473..176d427 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md @@ -5,7 +5,8 @@ description: > Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an - unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component. + unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component -> + `git-workflow`. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. diff --git a/plugins/gitea/skills/gitea-branches/SKILL.md b/plugins/gitea/skills/gitea-branches/SKILL.md index 96af538..4645153 100644 --- a/plugins/gitea/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/skills/gitea-branches/SKILL.md @@ -24,7 +24,7 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git - **404 often means 403.** Gitea masks permission errors as not-found; on an unexpected one, check token scope before reporting a branch or commit missing. - **Nothing auto-paginates.** `list_branches` and `list_commits` return one page; iterate `page` until the returned count is below `per_page`. -- **`delete_branch` has no force-push guard.** Check `protected` from `list_branches` first and require explicit confirmation — a protected branch need not be named `main`. +- **`delete_branch` has no force-push guard.** Treat deleting a protected branch as a hard refusal unless the user explicitly confirms it in the conversation. Check `protected` from `list_branches` first — a protected branch need not be named `main`. ## Step 1 — Resolve owner and repo diff --git a/plugins/gitea/skills/gitea-files/SKILL.md b/plugins/gitea/skills/gitea-files/SKILL.md index 555782e..401952a 100644 --- a/plugins/gitea/skills/gitea-files/SKILL.md +++ b/plugins/gitea/skills/gitea-files/SKILL.md @@ -3,8 +3,8 @@ name: gitea-files description: > Use when reading or writing files or directories in a Gitea repository via the MCP server, - rather than the local filesystem — even when the user does not say "Gitea". Not commit - history -> `gitea-branches`. Not pull requests -> `gitea-prs`. + rather than the local filesystem (for a local path use Read/Write/Edit) — even when the user + does not say "Gitea". Not commit history -> `gitea-branches`. Not pull requests -> `gitea-prs`. compatibility: Requires the Gitea MCP server configured with a token scoped to at least write:repository. Tested with a token holding write:issue + write:repository; write:issue diff --git a/plugins/gitea/skills/gitea-issues/SKILL.md b/plugins/gitea/skills/gitea-issues/SKILL.md index 898805e..72754b5 100644 --- a/plugins/gitea/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/skills/gitea-issues/SKILL.md @@ -2,8 +2,9 @@ name: gitea-issues description: > - Use when reading or writing Gitea issues — even when the user does not say "Gitea". - Not pull requests -> `gitea-prs`. + 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`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token @@ -25,14 +26,14 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`, never on a list item. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`. - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. -- **A merged PR leaves its issue open.** Gitea does not auto-close on merge the way GitHub does. Re-read the issue's state after a merge before closing it manually. +- **A merge does not itself close the issue.** Gitea has no close-on-merge event, but a `Fixes #N` in the merged commits can, depending on merge style (`gitea-prs`). Re-read its state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. ## Step 1 — Resolve owner and repo -An orchestrating caller may pass `owner` and `repo` in already; if so, skip this. The `search` row is cross-repository and needs only a query, so it skips this too. Otherwise, before any tool call: +An orchestrating caller may pass `owner` and `repo` in already, and the `search` row is cross-repository and needs only a query — both skip this step. Otherwise, before any tool call: ```bash git remote get-url origin @@ -58,7 +59,7 @@ One invocation takes one row. Read only the reference(s) that row names — the ## Step 3 — Create -Only the create flow reaches this step; every other row goes straight to its reference. +Only the create flow reaches this step. 1. Take `title` and `body` from conversation context — the most recent task, bug report, or explicit statement. An empty body is an acceptable fallback, an invented one is not. 2. Run the enrichments in `references/enrichments.md`, then create with the resolved IDs per `references/issues.md`. Omitting a parameter always beats guessing its value — a wrong milestone or assignee is harder to notice than a missing one. diff --git a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md index 02d090e..e0605ab 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/SKILL.md +++ b/plugins/gitea/skills/gitea-labels-milestones/SKILL.md @@ -2,8 +2,9 @@ name: gitea-labels-milestones description: > - Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer - labels — even when the user does not say "Gitea". + Use when reading or writing Gitea labels or milestones — "create a label", "what labels does + this repo have", "close the milestone" — or to resolve label names to IDs, or infer a + Kind/Priority/Status label from context, even when the user does not say "Gitea". Not applying them to an issue -> `gitea-issues`. Not to a PR -> `gitea-prs`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. diff --git a/plugins/gitea/skills/gitea-prs/SKILL.md b/plugins/gitea/skills/gitea-prs/SKILL.md index 236e009..1be7d5b 100644 --- a/plugins/gitea/skills/gitea-prs/SKILL.md +++ b/plugins/gitea/skills/gitea-prs/SKILL.md @@ -3,7 +3,9 @@ name: gitea-prs description: > Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even - when the user does not say "Gitea". Not issues -> `gitea-issues`. + when the user does not say "Gitea". A number the user names may be an issue or a PR — they share + one number space — so confirm which domain applies before dispatching. Not issues -> + `gitea-issues`. Not branch or commit operations -> `gitea-branches`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when diff --git a/plugins/gitea/skills/gitea-releases/SKILL.md b/plugins/gitea/skills/gitea-releases/SKILL.md index 1fbaa24..88a455b 100644 --- a/plugins/gitea/skills/gitea-releases/SKILL.md +++ b/plugins/gitea/skills/gitea-releases/SKILL.md @@ -2,9 +2,11 @@ name: gitea-releases description: > - Use when managing Gitea releases or the git tags underneath them — list, get, - create, or delete either — even when the user does not say "release" or - "Gitea". Not branches or commit history -> `gitea-branches`. + Use when managing Gitea releases or the git tags underneath them — list, get, create, or + delete either — even when the user does not say "release" or "Gitea": "cut a v1.2.0", + "publish a prerelease", "tag this commit", "what's the latest release". Not branches or + commit history -> `gitea-branches`. Not issues -> `gitea-issues`. Not pull requests -> + `gitea-prs`. compatibility: Requires Gitea MCP server configured with a token with write:repository scope, which gates every release and tag tool here. Requires git remote "origin" pointing to the Gitea instance @@ -55,7 +57,7 @@ If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea r `target` (on `create_release`/`create_tag`) is a commitish — a branch name, existing tag, or commit SHA — the point the new tag is cut from. -Pass a caller-supplied `tag_name` through verbatim — the API accepts any string, and semver with a `v` prefix is a tooling convention rather than a Gitea constraint. +Pass a caller-supplied `tag_name` through verbatim — the API accepts any string; semver is convention, not constraint. ## Step 3 — Procedure for the scenario in hand @@ -63,7 +65,7 @@ These four are mutually exclusive — pick the one row the request lands on. | Scenario | Procedure | |---|---| -| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release. | +| Create a release | Call `create_release` with `tag_name`, `target`, `title`, and `is_draft`/`is_pre_release` set explicitly — never left to default. This surface carries no update or edit tool, so a wrong flag is repairable only by delete-and-recreate (`references/conventions.md`). A separate `create_tag` is only needed to tag a commit without wrapping it in a release — whether `create_release` creates a missing tag is unconfirmed, so verify with `get_tag`. | | Delete a release | Resolve the numeric `id` per the first Gotcha, confirm intent, then call `delete_release`. The tag survives. | | Delete a tag along with its release | Delete the release first, then call `delete_tag` — confirm both are intended before proceeding, since each is irreversible on its own. | | List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. | diff --git a/plugins/gitea/skills/gitea-releases/references/conventions.md b/plugins/gitea/skills/gitea-releases/references/conventions.md index eabeeec..04c0059 100644 --- a/plugins/gitea/skills/gitea-releases/references/conventions.md +++ b/plugins/gitea/skills/gitea-releases/references/conventions.md @@ -12,11 +12,20 @@ additional tool schemas. ## Release wraps a tag, not the reverse -A release is a title, body (notes), and draft/prerelease flags layered on top of an existing or -newly-created tag. The tag is the git-level object (a name pointing at a commit); the release is a -Gitea-level metadata wrapper around it. This is why `delete_release` and `delete_tag` are separate -calls with separate identifiers (numeric id vs. tag name) — removing the wrapper never implies -removing the underlying pointer, and vice versa. +A release is a title, body (notes), and draft/prerelease flags layered on top of a tag. The tag is +the git-level object (a name pointing at a commit); the release is a Gitea-level metadata wrapper +around it. This is why `delete_release` and `delete_tag` are separate calls with separate +identifiers (numeric id vs. tag name) — removing the wrapper never implies removing the underlying +pointer, and vice versa. + +### Whether `create_release` creates a missing tag is unconfirmed + +`create_release` takes both `tag_name` and `target` (a commitish), and that shape *suggests* Gitea +creates the tag at `target` when `tag_name` does not already exist. That is inferred from the API +shape, not confirmed by any source this skill was built from (`references/sources.md`) — so treat it +as an assumption, not behaviour. When the caller depends on the tag existing, verify it afterward +with `get_tag` (or `list_tags`) rather than reporting it as created. `create_tag` is the only call +confirmed to create one. ## Semver tag naming diff --git a/plugins/gitea/skills/gitea-workflow/SKILL.md b/plugins/gitea/skills/gitea-workflow/SKILL.md index c92c473..176d427 100644 --- a/plugins/gitea/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/skills/gitea-workflow/SKILL.md @@ -5,7 +5,8 @@ description: > Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an - unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component. + unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component -> + `git-workflow`. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. -- 2.43.0 From ae791781c22b150baaad9f095c9703ff28fb546c Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:46:35 +0000 Subject: [PATCH 76/89] fix(git): restore router coverage and commands the retrofit dropped git-workflow calls itself a router but named two of the six domains it routes to; the other four appeared nowhere in the file. All six are now named, with a routing table in the always-loaded body. git-submodules lost the foreach shell-variable semantics -- only the bare names survived, though $sm_path and $displaypath differ solely by which directory you are in. The table is back. Its relocated commands had also dropped the rtk git prefix its own SKILL.md mandates; 24 of them are re-prefixed. The wider rtk inconsistency across the plugin stays with #113. Also restores git-commits' body and footers output fields, git-branches' tag/ branch detection commands, git-worktrees' git config --worktree, pc-run's ambiguity fallback, git-remotes' git-history boundary, and git-history's pickaxe triggers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- plugins/git/.apm/skills/git-branches/SKILL.md | 2 +- plugins/git/.apm/skills/git-commits/SKILL.md | 11 ++++- plugins/git/.apm/skills/git-history/SKILL.md | 8 ++-- plugins/git/.apm/skills/git-remotes/SKILL.md | 1 + .../git/.apm/skills/git-submodules/README.md | 2 +- .../git/.apm/skills/git-submodules/SKILL.md | 6 ++- .../git-submodules/references/README.md | 5 ++- .../git-submodules/references/removal.md | 12 +++--- .../references/setup-and-update.md | 42 ++++++++++++++----- .../references/urls-and-config.md | 18 ++++---- .../git/.apm/skills/git-workflow/README.md | 4 +- plugins/git/.apm/skills/git-workflow/SKILL.md | 26 ++++++++++-- .../git/.apm/skills/git-worktrees/SKILL.md | 2 +- plugins/git/.apm/skills/pc-run/SKILL.md | 3 ++ plugins/git/skills/git-branches/SKILL.md | 2 +- plugins/git/skills/git-commits/SKILL.md | 11 ++++- plugins/git/skills/git-history/SKILL.md | 8 ++-- plugins/git/skills/git-remotes/SKILL.md | 1 + plugins/git/skills/git-submodules/README.md | 2 +- plugins/git/skills/git-submodules/SKILL.md | 6 ++- .../git-submodules/references/README.md | 5 ++- .../git-submodules/references/removal.md | 12 +++--- .../references/setup-and-update.md | 42 ++++++++++++++----- .../references/urls-and-config.md | 18 ++++---- plugins/git/skills/git-workflow/README.md | 4 +- plugins/git/skills/git-workflow/SKILL.md | 26 ++++++++++-- plugins/git/skills/git-worktrees/SKILL.md | 2 +- plugins/git/skills/pc-run/SKILL.md | 3 ++ 28 files changed, 198 insertions(+), 86 deletions(-) diff --git a/plugins/git/.apm/skills/git-branches/SKILL.md b/plugins/git/.apm/skills/git-branches/SKILL.md index 7a2e715..ca98aaf 100644 --- a/plugins/git/.apm/skills/git-branches/SKILL.md +++ b/plugins/git/.apm/skills/git-branches/SKILL.md @@ -20,7 +20,7 @@ metadata: ## Gotchas - **Uncommitted changes abort a switch.** `git switch` refuses rather than clobbering conflicting local edits. Offer to stash and retry — forcing the checkout past it is how work disappears. -- **A branch and a tag can carry the same name.** Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. +- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list <name>` and `git tag --list <name>`; output from both means the name is ambiguous. Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. - **`main` and `master` are a refusal, not a gate.** Force-pushing, force-deleting, or renaming them is rejected even when the caller passes `confirm: true` — no flag makes the remote's history recoverable. Offer a new branch instead. ## Step 1 — Determine the branching pattern diff --git a/plugins/git/.apm/skills/git-commits/SKILL.md b/plugins/git/.apm/skills/git-commits/SKILL.md index 4cc71c2..3b9683c 100644 --- a/plugins/git/.apm/skills/git-commits/SKILL.md +++ b/plugins/git/.apm/skills/git-commits/SKILL.md @@ -57,8 +57,17 @@ For an agent caller, return: "semver_impact": "MAJOR|MINOR|PATCH|none", "breaking_change": false, "confirmation_required": false, - "details": { "type": "feat", "scope": "api", "description": "add user authentication" } + "details": { + "type": "feat", + "scope": "api", + "description": "add user authentication", + "body": "optional body text, or null", + "footers": ["Fixes: #123", "Refs: #456", "Co-authored-by: Bob <bob@example.com>"] + } } ``` +`details.footers` is an array of the resolved trailer lines, empty when there are none — never a +single joined string, and never omitted. Downstream agents index it. + For a human caller, show the same fields as a prose preview with a confirmation prompt. diff --git a/plugins/git/.apm/skills/git-history/SKILL.md b/plugins/git/.apm/skills/git-history/SKILL.md index a4ab045..bfb1fa9 100644 --- a/plugins/git/.apm/skills/git-history/SKILL.md +++ b/plugins/git/.apm/skills/git-history/SKILL.md @@ -2,10 +2,10 @@ name: git-history description: > - Use when investigating git history — querying logs, tracing when a change - landed, bisecting the commit that broke something, or locating one to - revert or backport. Not authoring or rebasing commits -> `git-commits`. - Not history on a Gitea server -> `gitea-branches`. + Use when investigating git history — pickaxe (`-S`/`-G`) or `-L` line-range log + queries, tracing when a change landed, bisecting what broke something, or + locating a commit to revert or backport. Not authoring or rebasing commits -> + `git-commits`. Not a Gitea server's history -> `gitea-branches`. metadata: category: git diff --git a/plugins/git/.apm/skills/git-remotes/SKILL.md b/plugins/git/.apm/skills/git-remotes/SKILL.md index 3cb6a01..5f457c1 100644 --- a/plugins/git/.apm/skills/git-remotes/SKILL.md +++ b/plugins/git/.apm/skills/git-remotes/SKILL.md @@ -6,6 +6,7 @@ description: > remote, even when the user does not name it. Not local commits -> `git-commits`. Not local branches -> `git-branches`. + Not log or bisect queries -> `git-history`. Not submodule pointers -> `git-submodules`. metadata: diff --git a/plugins/git/.apm/skills/git-submodules/README.md b/plugins/git/.apm/skills/git-submodules/README.md index 06aa066..4b1d2b9 100644 --- a/plugins/git/.apm/skills/git-submodules/README.md +++ b/plugins/git/.apm/skills/git-submodules/README.md @@ -29,7 +29,7 @@ conflicts, and a recovery `next_step` when applicable). |------|---------| | `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table | | `references/README.md` | Describes contents of references/ | -| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, or initializing, updating, or re-pinning one — includes the full `add` and `update` flag tables and the pinning workflows | +| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, initializing, updating, or re-pinning one, or running a command across all of them — includes the full `add` and `update` flag tables, the pinning workflows, and the `foreach` shell-variable table | | `references/urls-and-config.md` | Loaded when changing where a submodule points or how it is configured — `.gitmodules` vs `.git/config` anatomy, both key tables, `sync`/`set-url`/`set-branch`, local mirror overrides, relative URLs, the custom-`update` security gate, and `absorbgitdirs` | | `references/removal.md` | Loaded when removing or deinitializing a submodule — why `deinit` is not removal, and the four-step removal sequence | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/.apm/skills/git-submodules/SKILL.md b/plugins/git/.apm/skills/git-submodules/SKILL.md index e274475..297f6a2 100644 --- a/plugins/git/.apm/skills/git-submodules/SKILL.md +++ b/plugins/git/.apm/skills/git-submodules/SKILL.md @@ -34,7 +34,9 @@ them pins a state nobody else can reproduce. To run one command across every submodule: `rtk git submodule foreach --recursive '<cmd>'`. Inside `<cmd>`, Git sets `$name`, `$sm_path`, `$displaypath`, `$sha1` and `$toplevel`; append `|| :` to -continue past a failure instead of aborting the traversal. +continue past a failure instead of aborting the traversal. `$sm_path` and `$displaypath` name the +same directory from different vantage points — if which one you want is not obvious, read the +variable table in `references/setup-and-update.md` before writing the command. ## Dispatch @@ -42,7 +44,7 @@ Read only the row that matches the request. | Task | Reference | |---|---| -| Clone a superproject with submodules, or add, initialize, update or re-pin one | `references/setup-and-update.md` | +| Clone a superproject with submodules; add, initialize, update or re-pin one; run a command across all of them with `foreach` | `references/setup-and-update.md` | | Change where a submodule points — `sync`, `set-url`, `set-branch`, a local mirror override, `absorbgitdirs`, or any `.gitmodules` / `.git/config` key | `references/urls-and-config.md` | | Remove a submodule, or `deinit` one without removing it | `references/removal.md` | diff --git a/plugins/git/.apm/skills/git-submodules/references/README.md b/plugins/git/.apm/skills/git-submodules/references/README.md index 19b9784..0df0d93 100644 --- a/plugins/git/.apm/skills/git-submodules/references/README.md +++ b/plugins/git/.apm/skills/git-submodules/references/README.md @@ -10,8 +10,9 @@ One file per task branch in SKILL.md's dispatch table. Load only the one that ma ## setup-and-update.md Cloning a superproject that has submodules, adding a dependency as a submodule, initializing -without cloning, and updating or re-pinning. Carries the `add` and `update` flag tables and the -keep-pinned and move-the-pin-forward workflows. +without cloning, updating or re-pinning, and running one command across every submodule. Carries +the `add` and `update` flag tables, the keep-pinned and move-the-pin-forward workflows, and the +`foreach` shell-variable table (`$name`, `$sm_path`, `$displaypath`, `$sha1`, `$toplevel`). ## urls-and-config.md diff --git a/plugins/git/.apm/skills/git-submodules/references/removal.md b/plugins/git/.apm/skills/git-submodules/references/removal.md index 4bee62e..0b7d331 100644 --- a/plugins/git/.apm/skills/git-submodules/references/removal.md +++ b/plugins/git/.apm/skills/git-submodules/references/removal.md @@ -11,7 +11,7 @@ Both operations are destructive. Confirm with the user before executing either. ## `deinit` is not removal ```bash -git submodule deinit <path> # --all for every submodule, -f if locally modified +rtk git submodule deinit <path> # --all for every submodule, -f if locally modified ``` `deinit` clears the submodule's section from `.git/config` and empties its working tree. The @@ -22,11 +22,11 @@ or to reset a broken checkout, not to delete a dependency. ## Full removal, in order ```bash -git submodule deinit -f <path> # unregister from .git/config -git rm <path> # drop the .gitmodules entry and the gitlink from the index -rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git -git commit -m "chore: remove <name> submodule" +rtk git submodule deinit -f <path> # unregister from .git/config +rtk git rm <path> # drop the .gitmodules entry and the gitlink from the index +rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git +rtk git commit -m "chore: remove <name> submodule" ``` -The third step is the one that gets skipped. `.git/modules/<name>/` survives `git rm`, and while it +The third step is the one that gets skipped. `.git/modules/<name>/` survives `rtk git rm`, and while it is present Git refuses to add a submodule at the same path again. diff --git a/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md index e7bae51..dfb9c75 100644 --- a/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md +++ b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md @@ -9,16 +9,16 @@ source_keys: ## Clone a superproject that already has submodules ```bash -git clone --recurse-submodules <url> # Git 2.13+, one step +rtk git clone --recurse-submodules <url> # Git 2.13+, one step # or, against an existing clone -git submodule update --init --recursive +rtk git submodule update --init --recursive ``` ## Add a dependency as a submodule ```bash -git submodule add <url> <path> -git commit -m "chore: add <name> as submodule" +rtk git submodule add <url> <path> +rtk git commit -m "chore: add <name> as submodule" ``` `add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: @@ -32,14 +32,14 @@ git commit -m "chore: add <name> as submodule" ## Initialize without cloning -`git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and +`rtk git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and does nothing else. This is the point at which a local URL override can be edited before any fetch happens. If a local mirror override is wanted, read `references/urls-and-config.md` before running `update`. Use `update --init` to run both steps at once. ## Update -`git submodule update --init --recursive` is the common case: it clones what is missing and checks +`rtk git submodule update --init --recursive` is the common case: it clones what is missing and checks out the commit the superproject recorded, in detached HEAD. | Flag | Meaning | @@ -59,16 +59,38 @@ out the commit the superproject recorded, in detached HEAD. ## Keep submodules pinned to the recorded commit ```bash -git submodule update --recursive # after every git pull -git config submodule.recurse true # or do it automatically on pull/push/checkout +rtk git submodule update --recursive # after every rtk git pull +rtk git config submodule.recurse true # or do it automatically on pull/push/checkout ``` ## Move the pin forward to the tracked branch tip ```bash -git submodule update --remote --merge --recursive -git commit -am "chore: update submodules to latest" +rtk git submodule update --remote --merge --recursive +rtk git commit -am "chore: update submodules to latest" ``` `--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default branch. Commit the superproject afterwards or the new pin is lost on the next `update`. + +## Run one command across every submodule + +```bash +rtk git submodule foreach --recursive '<command>' +rtk git submodule foreach 'git pull origin main || :' # || : continues past a failure +``` + +`<command>` runs inside each submodule's own working tree, so the git calls in it are the +submodule's own — that is the one place a bare `git` is correct. Append `|| :` to keep the +traversal going instead of aborting at the first failure. + +Git exports five shell variables into `<command>`. `$sm_path` and `$displaypath` name the same +directory from different vantage points and are not interchangeable: + +| Variable | Meaning | +|---|---| +| `$name` | Logical submodule name (the `.gitmodules` section name, which need not match the path) | +| `$sm_path` | Path relative to the superproject root | +| `$displaypath` | Path relative to the current working directory | +| `$sha1` | Commit SHA the superproject has recorded for this submodule | +| `$toplevel` | Absolute path of the superproject's root | diff --git a/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md b/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md index 5d59691..596724b 100644 --- a/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md +++ b/plugins/git/.apm/skills/git-submodules/references/urls-and-config.md @@ -10,7 +10,7 @@ source_keys: - **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's logical name, path, and canonical URL. -- **`.git/config`** — local only, populated by `git submodule init`. Local URL overrides live here +- **`.git/config`** — local only, populated by `rtk git submodule init`. Local URL overrides live here and never propagate to another clone. The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is @@ -38,9 +38,9 @@ linked to the submodule's working tree by a `.git` pointer file. ## Rebind a URL or branch ```bash -git submodule sync --recursive # push .gitmodules URLs into .git/config -git submodule set-url <path> <url> # change the canonical URL -git submodule set-branch -b <branch> <path> # set the branch used by update --remote +rtk git submodule sync --recursive # push .gitmodules URLs into .git/config +rtk git submodule set-url <path> <url> # change the canonical URL +rtk git submodule set-branch -b <branch> <path> # set the branch used by update --remote ``` Run `sync` after an upstream rename: existing clones keep the stale URL in `.git/config` until @@ -49,9 +49,9 @@ they do. ## Override a URL locally (private mirror) ```bash -git submodule init +rtk git submodule init # edit .git/config: submodule.<name>.url = <mirror-url> -git submodule update +rtk git submodule update ``` Local-only, invisible to collaborators, and overwritten by the next `sync`. @@ -65,15 +65,15 @@ everywhere else. ## Custom `update` commands are security-gated A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by -`git submodule init`. That is deliberate: it stops a hostile clone from silently executing +`rtk git submodule init`. That is deliberate: it stops a hostile clone from silently executing arbitrary code. Setting it locally in `.git/config` is the only way to enable it. ## Relocate an embedded `.git` directory ```bash -git submodule absorbgitdirs [<path>...] +rtk git submodule absorbgitdirs [<path>...] ``` Moves a submodule's own `.git` directory into `.git/modules/<name>/` and leaves a `.git` pointer file behind. Needed when a nested repository was created or copied in without going through -`git submodule add`. +`rtk git submodule add`. diff --git a/plugins/git/.apm/skills/git-workflow/README.md b/plugins/git/.apm/skills/git-workflow/README.md index 607924f..10659fd 100644 --- a/plugins/git/.apm/skills/git-workflow/README.md +++ b/plugins/git/.apm/skills/git-workflow/README.md @@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp ## What it does -This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. +This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It is the router for the six local-git domain skills — `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules` and `git-worktrees` — and `SKILL.md` carries a table mapping each of them to the requests it owns, so an ambiguous request resolves to exactly one domain before anything runs. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. ## Usage @@ -18,7 +18,7 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — the six-domain routing table, the workflow steps, and the interaction style | | `README.md` | This file | | `references/hard-rules.md` | The org's non-negotiable git rules; read when a request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials | | `references/README.md` | Describes the references directory contents | diff --git a/plugins/git/.apm/skills/git-workflow/SKILL.md b/plugins/git/.apm/skills/git-workflow/SKILL.md index e7e5034..40422fb 100644 --- a/plugins/git/.apm/skills/git-workflow/SKILL.md +++ b/plugins/git/.apm/skills/git-workflow/SKILL.md @@ -3,8 +3,9 @@ name: git-workflow description: > Use when a human's local git request is general or ambiguous — it routes to the owning - domain skill. Not an unambiguous commit -> `git-commits`. Not an unambiguous branch -> - `git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. + domain skill: `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules` + or `git-worktrees`. An unambiguous request goes straight to its domain skill instead. Not an + agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. metadata: category: git @@ -25,10 +26,27 @@ metadata: mandated org wrapper, not a style preference. Submodule-specific commands run from inside the submodule's own directory instead. +## Domains + +Every request resolves to exactly one of these six. An unambiguous one should have gone straight +to the domain skill; this skill exists for the ones that did not. + +| The request is about | Domain | +|---|---| +| Writing, amending, squashing, or cherry-picking a commit, and its message | `git-commits` | +| Creating, switching, deleting, renaming, tracking, or merging a local branch | `git-branches` | +| When a change landed, which commit broke something, what to revert or backport | `git-history` | +| Anything touching a remote — remote config, fetch, push, pull — even unnamed | `git-remotes` | +| A nested repository pinned inside this one by a recorded commit | `git-submodules` | +| Several branches checked out at once, in separate directories, without stashing | `git-worktrees` | + +`git-orchestrate` executes whatever this resolves to (step 5); the table only decides which domain +owns the request. + ## Workflow -1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) - and any options the user named. +1. **Parse intent** — extract the operation and, from the table above, the domain that owns it, + plus any options the user named. 2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule before acting, not after. diff --git a/plugins/git/.apm/skills/git-worktrees/SKILL.md b/plugins/git/.apm/skills/git-worktrees/SKILL.md index f89dcfe..15a89fc 100644 --- a/plugins/git/.apm/skills/git-worktrees/SKILL.md +++ b/plugins/git/.apm/skills/git-worktrees/SKILL.md @@ -18,7 +18,7 @@ metadata: - **A branch can be checked out in only one worktree at a time.** `git worktree add` on an already-checked-out branch fails; `--force` is the only override, so use it only deliberately. - **Never `rm -rf` a worktree directory.** That strands metadata in `$GIT_DIR/worktrees/`. Use `git worktree remove`, or `git worktree prune` afterwards. - **Submodules break worktree support.** A worktree containing submodules cannot be moved at all, and needs `--force` to remove. -- **`extensions.worktreeConfig = true` is a one-way door.** It costs compatibility with older Git and forces `core.bare`/`core.worktree` into `config.worktree`. Leave it off unless per-worktree config is needed. +- **`extensions.worktreeConfig = true` is a one-way door.** Without it, `git config --worktree` errors; with it, that flag writes to the worktree's own `config.worktree` file, and `core.bare`/`core.worktree` are forced there too. It also breaks older Git. Leave it off unless per-worktree config is needed. ## Step 1 — Dispatch diff --git a/plugins/git/.apm/skills/pc-run/SKILL.md b/plugins/git/.apm/skills/pc-run/SKILL.md index bc29166..82ebc7a 100644 --- a/plugins/git/.apm/skills/pc-run/SKILL.md +++ b/plugins/git/.apm/skills/pc-run/SKILL.md @@ -47,6 +47,9 @@ Determine intent from the user's request, then execute the matching operation. W | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | | "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | +If the intent is ambiguous, default to `pre-commit run --all-files` — do not stop to ask, and do +not fall through to a narrower row on a guess. + ## Run Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. diff --git a/plugins/git/skills/git-branches/SKILL.md b/plugins/git/skills/git-branches/SKILL.md index 7a2e715..ca98aaf 100644 --- a/plugins/git/skills/git-branches/SKILL.md +++ b/plugins/git/skills/git-branches/SKILL.md @@ -20,7 +20,7 @@ metadata: ## Gotchas - **Uncommitted changes abort a switch.** `git switch` refuses rather than clobbering conflicting local edits. Offer to stash and retry — forcing the checkout past it is how work disappears. -- **A branch and a tag can carry the same name.** Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. +- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list <name>` and `git tag --list <name>`; output from both means the name is ambiguous. Prefer `git switch` over `git checkout`, and where a command accepts either ref, disambiguate with `refs/heads/<name>` or `refs/tags/<name>`. - **`main` and `master` are a refusal, not a gate.** Force-pushing, force-deleting, or renaming them is rejected even when the caller passes `confirm: true` — no flag makes the remote's history recoverable. Offer a new branch instead. ## Step 1 — Determine the branching pattern diff --git a/plugins/git/skills/git-commits/SKILL.md b/plugins/git/skills/git-commits/SKILL.md index 4cc71c2..3b9683c 100644 --- a/plugins/git/skills/git-commits/SKILL.md +++ b/plugins/git/skills/git-commits/SKILL.md @@ -57,8 +57,17 @@ For an agent caller, return: "semver_impact": "MAJOR|MINOR|PATCH|none", "breaking_change": false, "confirmation_required": false, - "details": { "type": "feat", "scope": "api", "description": "add user authentication" } + "details": { + "type": "feat", + "scope": "api", + "description": "add user authentication", + "body": "optional body text, or null", + "footers": ["Fixes: #123", "Refs: #456", "Co-authored-by: Bob <bob@example.com>"] + } } ``` +`details.footers` is an array of the resolved trailer lines, empty when there are none — never a +single joined string, and never omitted. Downstream agents index it. + For a human caller, show the same fields as a prose preview with a confirmation prompt. diff --git a/plugins/git/skills/git-history/SKILL.md b/plugins/git/skills/git-history/SKILL.md index a4ab045..bfb1fa9 100644 --- a/plugins/git/skills/git-history/SKILL.md +++ b/plugins/git/skills/git-history/SKILL.md @@ -2,10 +2,10 @@ name: git-history description: > - Use when investigating git history — querying logs, tracing when a change - landed, bisecting the commit that broke something, or locating one to - revert or backport. Not authoring or rebasing commits -> `git-commits`. - Not history on a Gitea server -> `gitea-branches`. + Use when investigating git history — pickaxe (`-S`/`-G`) or `-L` line-range log + queries, tracing when a change landed, bisecting what broke something, or + locating a commit to revert or backport. Not authoring or rebasing commits -> + `git-commits`. Not a Gitea server's history -> `gitea-branches`. metadata: category: git diff --git a/plugins/git/skills/git-remotes/SKILL.md b/plugins/git/skills/git-remotes/SKILL.md index 3cb6a01..5f457c1 100644 --- a/plugins/git/skills/git-remotes/SKILL.md +++ b/plugins/git/skills/git-remotes/SKILL.md @@ -6,6 +6,7 @@ description: > remote, even when the user does not name it. Not local commits -> `git-commits`. Not local branches -> `git-branches`. + Not log or bisect queries -> `git-history`. Not submodule pointers -> `git-submodules`. metadata: diff --git a/plugins/git/skills/git-submodules/README.md b/plugins/git/skills/git-submodules/README.md index 06aa066..4b1d2b9 100644 --- a/plugins/git/skills/git-submodules/README.md +++ b/plugins/git/skills/git-submodules/README.md @@ -29,7 +29,7 @@ conflicts, and a recovery `next_step` when applicable). |------|---------| | `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table | | `references/README.md` | Describes contents of references/ | -| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, or initializing, updating, or re-pinning one — includes the full `add` and `update` flag tables and the pinning workflows | +| `references/setup-and-update.md` | Loaded when cloning a superproject, adding a submodule, initializing, updating, or re-pinning one, or running a command across all of them — includes the full `add` and `update` flag tables, the pinning workflows, and the `foreach` shell-variable table | | `references/urls-and-config.md` | Loaded when changing where a submodule points or how it is configured — `.gitmodules` vs `.git/config` anatomy, both key tables, `sync`/`set-url`/`set-branch`, local mirror overrides, relative URLs, the custom-`update` security gate, and `absorbgitdirs` | | `references/removal.md` | Loaded when removing or deinitializing a submodule — why `deinit` is not removal, and the four-step removal sequence | | `references/sources.md` | Research sources and provenance | diff --git a/plugins/git/skills/git-submodules/SKILL.md b/plugins/git/skills/git-submodules/SKILL.md index e274475..297f6a2 100644 --- a/plugins/git/skills/git-submodules/SKILL.md +++ b/plugins/git/skills/git-submodules/SKILL.md @@ -34,7 +34,9 @@ them pins a state nobody else can reproduce. To run one command across every submodule: `rtk git submodule foreach --recursive '<cmd>'`. Inside `<cmd>`, Git sets `$name`, `$sm_path`, `$displaypath`, `$sha1` and `$toplevel`; append `|| :` to -continue past a failure instead of aborting the traversal. +continue past a failure instead of aborting the traversal. `$sm_path` and `$displaypath` name the +same directory from different vantage points — if which one you want is not obvious, read the +variable table in `references/setup-and-update.md` before writing the command. ## Dispatch @@ -42,7 +44,7 @@ Read only the row that matches the request. | Task | Reference | |---|---| -| Clone a superproject with submodules, or add, initialize, update or re-pin one | `references/setup-and-update.md` | +| Clone a superproject with submodules; add, initialize, update or re-pin one; run a command across all of them with `foreach` | `references/setup-and-update.md` | | Change where a submodule points — `sync`, `set-url`, `set-branch`, a local mirror override, `absorbgitdirs`, or any `.gitmodules` / `.git/config` key | `references/urls-and-config.md` | | Remove a submodule, or `deinit` one without removing it | `references/removal.md` | diff --git a/plugins/git/skills/git-submodules/references/README.md b/plugins/git/skills/git-submodules/references/README.md index 19b9784..0df0d93 100644 --- a/plugins/git/skills/git-submodules/references/README.md +++ b/plugins/git/skills/git-submodules/references/README.md @@ -10,8 +10,9 @@ One file per task branch in SKILL.md's dispatch table. Load only the one that ma ## setup-and-update.md Cloning a superproject that has submodules, adding a dependency as a submodule, initializing -without cloning, and updating or re-pinning. Carries the `add` and `update` flag tables and the -keep-pinned and move-the-pin-forward workflows. +without cloning, updating or re-pinning, and running one command across every submodule. Carries +the `add` and `update` flag tables, the keep-pinned and move-the-pin-forward workflows, and the +`foreach` shell-variable table (`$name`, `$sm_path`, `$displaypath`, `$sha1`, `$toplevel`). ## urls-and-config.md diff --git a/plugins/git/skills/git-submodules/references/removal.md b/plugins/git/skills/git-submodules/references/removal.md index 4bee62e..0b7d331 100644 --- a/plugins/git/skills/git-submodules/references/removal.md +++ b/plugins/git/skills/git-submodules/references/removal.md @@ -11,7 +11,7 @@ Both operations are destructive. Confirm with the user before executing either. ## `deinit` is not removal ```bash -git submodule deinit <path> # --all for every submodule, -f if locally modified +rtk git submodule deinit <path> # --all for every submodule, -f if locally modified ``` `deinit` clears the submodule's section from `.git/config` and empties its working tree. The @@ -22,11 +22,11 @@ or to reset a broken checkout, not to delete a dependency. ## Full removal, in order ```bash -git submodule deinit -f <path> # unregister from .git/config -git rm <path> # drop the .gitmodules entry and the gitlink from the index -rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git -git commit -m "chore: remove <name> submodule" +rtk git submodule deinit -f <path> # unregister from .git/config +rtk git rm <path> # drop the .gitmodules entry and the gitlink from the index +rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git +rtk git commit -m "chore: remove <name> submodule" ``` -The third step is the one that gets skipped. `.git/modules/<name>/` survives `git rm`, and while it +The third step is the one that gets skipped. `.git/modules/<name>/` survives `rtk git rm`, and while it is present Git refuses to add a submodule at the same path again. diff --git a/plugins/git/skills/git-submodules/references/setup-and-update.md b/plugins/git/skills/git-submodules/references/setup-and-update.md index e7bae51..dfb9c75 100644 --- a/plugins/git/skills/git-submodules/references/setup-and-update.md +++ b/plugins/git/skills/git-submodules/references/setup-and-update.md @@ -9,16 +9,16 @@ source_keys: ## Clone a superproject that already has submodules ```bash -git clone --recurse-submodules <url> # Git 2.13+, one step +rtk git clone --recurse-submodules <url> # Git 2.13+, one step # or, against an existing clone -git submodule update --init --recursive +rtk git submodule update --init --recursive ``` ## Add a dependency as a submodule ```bash -git submodule add <url> <path> -git commit -m "chore: add <name> as submodule" +rtk git submodule add <url> <path> +rtk git commit -m "chore: add <name> as submodule" ``` `add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: @@ -32,14 +32,14 @@ git commit -m "chore: add <name> as submodule" ## Initialize without cloning -`git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and +`rtk git submodule init [<path>...]` copies submodule URLs from `.gitmodules` into `.git/config` and does nothing else. This is the point at which a local URL override can be edited before any fetch happens. If a local mirror override is wanted, read `references/urls-and-config.md` before running `update`. Use `update --init` to run both steps at once. ## Update -`git submodule update --init --recursive` is the common case: it clones what is missing and checks +`rtk git submodule update --init --recursive` is the common case: it clones what is missing and checks out the commit the superproject recorded, in detached HEAD. | Flag | Meaning | @@ -59,16 +59,38 @@ out the commit the superproject recorded, in detached HEAD. ## Keep submodules pinned to the recorded commit ```bash -git submodule update --recursive # after every git pull -git config submodule.recurse true # or do it automatically on pull/push/checkout +rtk git submodule update --recursive # after every rtk git pull +rtk git config submodule.recurse true # or do it automatically on pull/push/checkout ``` ## Move the pin forward to the tracked branch tip ```bash -git submodule update --remote --merge --recursive -git commit -am "chore: update submodules to latest" +rtk git submodule update --remote --merge --recursive +rtk git commit -am "chore: update submodules to latest" ``` `--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default branch. Commit the superproject afterwards or the new pin is lost on the next `update`. + +## Run one command across every submodule + +```bash +rtk git submodule foreach --recursive '<command>' +rtk git submodule foreach 'git pull origin main || :' # || : continues past a failure +``` + +`<command>` runs inside each submodule's own working tree, so the git calls in it are the +submodule's own — that is the one place a bare `git` is correct. Append `|| :` to keep the +traversal going instead of aborting at the first failure. + +Git exports five shell variables into `<command>`. `$sm_path` and `$displaypath` name the same +directory from different vantage points and are not interchangeable: + +| Variable | Meaning | +|---|---| +| `$name` | Logical submodule name (the `.gitmodules` section name, which need not match the path) | +| `$sm_path` | Path relative to the superproject root | +| `$displaypath` | Path relative to the current working directory | +| `$sha1` | Commit SHA the superproject has recorded for this submodule | +| `$toplevel` | Absolute path of the superproject's root | diff --git a/plugins/git/skills/git-submodules/references/urls-and-config.md b/plugins/git/skills/git-submodules/references/urls-and-config.md index 5d59691..596724b 100644 --- a/plugins/git/skills/git-submodules/references/urls-and-config.md +++ b/plugins/git/skills/git-submodules/references/urls-and-config.md @@ -10,7 +10,7 @@ source_keys: - **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's logical name, path, and canonical URL. -- **`.git/config`** — local only, populated by `git submodule init`. Local URL overrides live here +- **`.git/config`** — local only, populated by `rtk git submodule init`. Local URL overrides live here and never propagate to another clone. The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is @@ -38,9 +38,9 @@ linked to the submodule's working tree by a `.git` pointer file. ## Rebind a URL or branch ```bash -git submodule sync --recursive # push .gitmodules URLs into .git/config -git submodule set-url <path> <url> # change the canonical URL -git submodule set-branch -b <branch> <path> # set the branch used by update --remote +rtk git submodule sync --recursive # push .gitmodules URLs into .git/config +rtk git submodule set-url <path> <url> # change the canonical URL +rtk git submodule set-branch -b <branch> <path> # set the branch used by update --remote ``` Run `sync` after an upstream rename: existing clones keep the stale URL in `.git/config` until @@ -49,9 +49,9 @@ they do. ## Override a URL locally (private mirror) ```bash -git submodule init +rtk git submodule init # edit .git/config: submodule.<name>.url = <mirror-url> -git submodule update +rtk git submodule update ``` Local-only, invisible to collaborators, and overwritten by the next `sync`. @@ -65,15 +65,15 @@ everywhere else. ## Custom `update` commands are security-gated A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by -`git submodule init`. That is deliberate: it stops a hostile clone from silently executing +`rtk git submodule init`. That is deliberate: it stops a hostile clone from silently executing arbitrary code. Setting it locally in `.git/config` is the only way to enable it. ## Relocate an embedded `.git` directory ```bash -git submodule absorbgitdirs [<path>...] +rtk git submodule absorbgitdirs [<path>...] ``` Moves a submodule's own `.git` directory into `.git/modules/<name>/` and leaves a `.git` pointer file behind. Needed when a nested repository was created or copied in without going through -`git submodule add`. +`rtk git submodule add`. diff --git a/plugins/git/skills/git-workflow/README.md b/plugins/git/skills/git-workflow/README.md index 607924f..10659fd 100644 --- a/plugins/git/skills/git-workflow/README.md +++ b/plugins/git/skills/git-workflow/README.md @@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp ## What it does -This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It handles commits, branch management, history inspection, submodules, worktrees, and remotes. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. +This skill wraps the `git-orchestrate` agent to provide an interactive, educational interface for humans performing git workflows. It is the router for the six local-git domain skills — `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules` and `git-worktrees` — and `SKILL.md` carries a table mapping each of them to the requests it owns, so an ambiguous request resolves to exactly one domain before anything runs. The skill parses user intent, gathers session context, invokes the orchestrator, and presents results in plain language with inline help, progress updates, and explanations of what's happening. It enforces confirmation gates for destructive operations (force-push, branch deletion, rebasing with history loss, force-checkout) and provides best-practices guidance throughout. The org's non-negotiable git rules live in `references/hard-rules.md` and are loaded only when a request could conflict with one. ## Usage @@ -18,7 +18,7 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma | File | Purpose | |------|---------| -| `SKILL.md` | Skill instructions for agents | +| `SKILL.md` | Skill instructions for agents — the six-domain routing table, the workflow steps, and the interaction style | | `README.md` | This file | | `references/hard-rules.md` | The org's non-negotiable git rules; read when a request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials | | `references/README.md` | Describes the references directory contents | diff --git a/plugins/git/skills/git-workflow/SKILL.md b/plugins/git/skills/git-workflow/SKILL.md index e7e5034..40422fb 100644 --- a/plugins/git/skills/git-workflow/SKILL.md +++ b/plugins/git/skills/git-workflow/SKILL.md @@ -3,8 +3,9 @@ name: git-workflow description: > Use when a human's local git request is general or ambiguous — it routes to the owning - domain skill. Not an unambiguous commit -> `git-commits`. Not an unambiguous branch -> - `git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. + domain skill: `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules` + or `git-worktrees`. An unambiguous request goes straight to its domain skill instead. Not an + agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. metadata: category: git @@ -25,10 +26,27 @@ metadata: mandated org wrapper, not a style preference. Submodule-specific commands run from inside the submodule's own directory instead. +## Domains + +Every request resolves to exactly one of these six. An unambiguous one should have gone straight +to the domain skill; this skill exists for the ones that did not. + +| The request is about | Domain | +|---|---| +| Writing, amending, squashing, or cherry-picking a commit, and its message | `git-commits` | +| Creating, switching, deleting, renaming, tracking, or merging a local branch | `git-branches` | +| When a change landed, which commit broke something, what to revert or backport | `git-history` | +| Anything touching a remote — remote config, fetch, push, pull — even unnamed | `git-remotes` | +| A nested repository pinned inside this one by a recorded commit | `git-submodules` | +| Several branches checked out at once, in separate directories, without stashing | `git-worktrees` | + +`git-orchestrate` executes whatever this resolves to (step 5); the table only decides which domain +owns the request. + ## Workflow -1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) - and any options the user named. +1. **Parse intent** — extract the operation and, from the table above, the domain that owns it, + plus any options the user named. 2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule before acting, not after. diff --git a/plugins/git/skills/git-worktrees/SKILL.md b/plugins/git/skills/git-worktrees/SKILL.md index f89dcfe..15a89fc 100644 --- a/plugins/git/skills/git-worktrees/SKILL.md +++ b/plugins/git/skills/git-worktrees/SKILL.md @@ -18,7 +18,7 @@ metadata: - **A branch can be checked out in only one worktree at a time.** `git worktree add` on an already-checked-out branch fails; `--force` is the only override, so use it only deliberately. - **Never `rm -rf` a worktree directory.** That strands metadata in `$GIT_DIR/worktrees/`. Use `git worktree remove`, or `git worktree prune` afterwards. - **Submodules break worktree support.** A worktree containing submodules cannot be moved at all, and needs `--force` to remove. -- **`extensions.worktreeConfig = true` is a one-way door.** It costs compatibility with older Git and forces `core.bare`/`core.worktree` into `config.worktree`. Leave it off unless per-worktree config is needed. +- **`extensions.worktreeConfig = true` is a one-way door.** Without it, `git config --worktree` errors; with it, that flag writes to the worktree's own `config.worktree` file, and `core.bare`/`core.worktree` are forced there too. It also breaks older Git. Leave it off unless per-worktree config is needed. ## Step 1 — Dispatch diff --git a/plugins/git/skills/pc-run/SKILL.md b/plugins/git/skills/pc-run/SKILL.md index bc29166..82ebc7a 100644 --- a/plugins/git/skills/pc-run/SKILL.md +++ b/plugins/git/skills/pc-run/SKILL.md @@ -47,6 +47,9 @@ Determine intent from the user's request, then execute the matching operation. W | "clean", "wipe cache", "rebuild from scratch" | `pre-commit clean` — read `references/clean.md` | | "hooks aren't running", "hook never fires", "why did a hook fail", a hook failure whose cause is unclear | Diagnose — read `references/failure-patterns.md` | +If the intent is ambiguous, default to `pre-commit run --all-files` — do not stop to ask, and do +not fall through to a narrower row on a guess. + ## Run Default to `pre-commit run --all-files`; never silently narrow to staged files. Run `pre-commit run` (staged only) or `pre-commit run <hook-id>` (one named hook) when the user asks for it. -- 2.43.0 From 4011d149bccb8dd7f1bfc4e34592f099670a3ed6 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:47:00 +0000 Subject: [PATCH 77/89] fix(bin): put five skills' boundaries where the router can read them grill-me, grill-with-docs, improve-codebase-architecture, tdd and triage each had their routing boundary written into README.md, which nothing loads at runtime, while the gate still reported all five descriptions as boundary-less. The boundaries move into the descriptions; write-docs' clause, which said 'those have dedicated skills' without naming one, now names them. research had moved its body out and then read both references unconditionally -- the anti-goal ADR-0020 names, where the word count moves and the per-run context does not. Both loads are genuinely conditional now, with the topic list and the four literal sources.md field names inlined, since the provenance validator matches those literally. Also restores the promote-the-prototype anti-pattern to prototype's ui.md, which the gate does not measure, so deleting it bought nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- plugins/bin/.apm/skills/grill-me/SKILL.md | 6 ++++- .../bin/.apm/skills/grill-with-docs/SKILL.md | 5 ++++- .../improve-codebase-architecture/SKILL.md | 6 ++++- .../.apm/skills/prototype/references/ui.md | 1 + plugins/bin/.apm/skills/research/README.md | 6 ++--- plugins/bin/.apm/skills/research/SKILL.md | 22 ++++++++++++++----- plugins/bin/.apm/skills/tdd/SKILL.md | 5 ++++- plugins/bin/.apm/skills/triage/SKILL.md | 5 ++++- plugins/bin/.apm/skills/write-docs/SKILL.md | 5 ++++- plugins/bin/skills/grill-me/SKILL.md | 6 ++++- plugins/bin/skills/grill-with-docs/SKILL.md | 5 ++++- .../improve-codebase-architecture/SKILL.md | 6 ++++- plugins/bin/skills/prototype/references/ui.md | 1 + plugins/bin/skills/research/README.md | 6 ++--- plugins/bin/skills/research/SKILL.md | 22 ++++++++++++++----- plugins/bin/skills/tdd/SKILL.md | 5 ++++- plugins/bin/skills/triage/SKILL.md | 5 ++++- plugins/bin/skills/write-docs/SKILL.md | 5 ++++- 18 files changed, 94 insertions(+), 28 deletions(-) diff --git a/plugins/bin/.apm/skills/grill-me/SKILL.md b/plugins/bin/.apm/skills/grill-me/SKILL.md index bd04394..1cc8ede 100644 --- a/plugins/bin/.apm/skills/grill-me/SKILL.md +++ b/plugins/bin/.apm/skills/grill-me/SKILL.md @@ -1,6 +1,10 @@ --- name: grill-me -description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +description: > + Use when the user says "grill me" or wants a plan or design stress-tested by + relentless interview — one question at a time, down each branch of the + decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs -> + `grill-with-docs`. --- Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. diff --git a/plugins/bin/.apm/skills/grill-with-docs/SKILL.md b/plugins/bin/.apm/skills/grill-with-docs/SKILL.md index 6dad6ad..8610263 100644 --- a/plugins/bin/.apm/skills/grill-with-docs/SKILL.md +++ b/plugins/bin/.apm/skills/grill-with-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: grill-with-docs -description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +description: > + Use when a plan should be stress-tested against the project's domain model — + the interview challenges terms against `CONTEXT.md` and writes decisions into + it and into ADRs as they land. Not a plain interview -> `grill-me`. --- <what-to-do> diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md index 22d02fb..6ec346b 100644 --- a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md @@ -1,6 +1,10 @@ --- name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +description: > + Use when the user wants a codebase's architecture improved — deepening + opportunities that turn shallow modules into deep ones, informed by + `CONTEXT.md` and `docs/adr/`. Not debugging a failure -> `diagnose`. Not + test-first feature work -> `tdd`. --- # Improve Codebase Architecture diff --git a/plugins/bin/.apm/skills/prototype/references/ui.md b/plugins/bin/.apm/skills/prototype/references/ui.md index 4de17de..9afb121 100644 --- a/plugins/bin/.apm/skills/prototype/references/ui.md +++ b/plugins/bin/.apm/skills/prototype/references/ui.md @@ -109,3 +109,4 @@ Don't leave variant components or the switcher lying around. They rot fast and c - **Variants that differ only in colour or copy.** That's a tweak, not a prototype. Real variants disagree about structure. - **Sharing too much code between variants.** A shared `<Header>` is fine; a shared `<Layout>` defeats the point. Each variant should be free to throw out the layout. - **Wiring variants to real mutations.** Read-only prototypes are fine. If a variant needs to mutate, point it at a stub — the question is "what should this look like", not "does the backend work". +- **Promoting the prototype directly to production.** The variant code was written under prototype constraints (no tests, minimal error handling). Rewrite it properly when you fold it in. diff --git a/plugins/bin/.apm/skills/research/README.md b/plugins/bin/.apm/skills/research/README.md index b98c00b..976cd2f 100644 --- a/plugins/bin/.apm/skills/research/README.md +++ b/plugins/bin/.apm/skills/research/README.md @@ -12,7 +12,7 @@ The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably ## Composition -`references/file-format.md` is not optional reading before the write step: the `sources.md` field names it defines are matched literally by the downstream provenance validator. Prose written in their place parses as nothing and the check passes having verified nothing. +Both reference files are read on condition, never on every run — `SKILL.md` inlines the minimum each step needs (the seven default topic areas at step 1, the four `sources.md` field names and the topic-file frontmatter keys at step 6) and sends the run to the reference only for what it does not carry. Those four field names are matched literally by the downstream provenance validator, so prose written in their place parses as nothing and the check passes having verified nothing — which is why they are inlined rather than deferred. ## Usage @@ -27,5 +27,5 @@ Name the topic and the output path — the skill will stop and ask if the path i | File | Purpose | |------|---------| | `SKILL.md` | The four gotchas and the six research steps | -| `references/topics.md` | Read at Step 1 before narrowing scope: the default topic list (`overview`, `installation`, `configuration`, `cli-reference`, `api-reference`, `examples`, and more) and what each file covers | -| `references/file-format.md` | Read at Step 6 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | +| `references/topics.md` | Read at Step 1 only when what belongs in a default topic is unclear or a custom topic is needed: the per-topic coverage table and the custom-topic naming rule | +| `references/file-format.md` | Read at Step 6 only when the inlined field names do not settle the case: slug derivation, the Context7 slug and URL convention, and what belongs in a topic body | diff --git a/plugins/bin/.apm/skills/research/SKILL.md b/plugins/bin/.apm/skills/research/SKILL.md index 1779011..19fffee 100644 --- a/plugins/bin/.apm/skills/research/SKILL.md +++ b/plugins/bin/.apm/skills/research/SKILL.md @@ -30,7 +30,10 @@ model: sonnet Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -Read `references/topics.md` before narrowing, for the default topic list. +The default topic areas are `overview`, `installation`, `configuration`, `cli-reference`, +`api-reference`, `examples` and `troubleshooting` — one file each, and only where content exists. +If what belongs in one of them is unclear, or the topic needs a file outside that set, read +`references/topics.md` for the per-topic coverage table and the custom-topic naming rule. ## Step 2 — Resolve against Context7 @@ -56,11 +59,20 @@ If nothing usable comes back, stop and report what was searched, then ask for st ## Step 6 — Write -Merge every set of notes, Context7 and web alike, by topic area. Read `references/file-format.md`, then write, in the output path: +Merge every set of notes, Context7 and web alike, by topic area, then write, in the output path: -- `<topic>.md` for each topic area that has content, default or custom -- `sources.md`, always, one section per source in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` +- `<topic>.md` for each topic area that has content, default or custom. Frontmatter carries `topic:` (the filename without `.md`) and `source_keys:` (kebab-case slugs matching `sources.md`); the body is prose in `##` sections, with no inline URLs. +- `sources.md`, always, one `##` section per source — including sources that yielded nothing — with exactly these four fields: -Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. + ```markdown + - **URL:** <full URL> + - **Description:** <one-line summary> + - **Contributing files:** <topic files this source contributed to> + - **Status:** `extracted` | `no content extracted` + ``` + +Spell those four field names exactly as given. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. + +Read `references/file-format.md` when the four fields above do not settle the case: what a slug should be, the `context7-<library-slug>` slug and `context7:<library-id>` URL convention for a Context7 source, or what belongs in a topic body versus a verbatim copy of the source. If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/.apm/skills/tdd/SKILL.md b/plugins/bin/.apm/skills/tdd/SKILL.md index 7a98941..d4a97f6 100644 --- a/plugins/bin/.apm/skills/tdd/SKILL.md +++ b/plugins/bin/.apm/skills/tdd/SKILL.md @@ -1,6 +1,9 @@ --- name: tdd -description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +description: > + Use when the user wants a feature built or a bug fixed test-first, in a strict + red-green-refactor loop, one behaviour at a time. Not diagnosing an existing + bug -> `diagnose`. Not throwaway exploratory code -> `prototype`. --- # Test-Driven Development diff --git a/plugins/bin/.apm/skills/triage/SKILL.md b/plugins/bin/.apm/skills/triage/SKILL.md index 0254fae..d83ab5e 100644 --- a/plugins/bin/.apm/skills/triage/SKILL.md +++ b/plugins/bin/.apm/skills/triage/SKILL.md @@ -1,6 +1,9 @@ --- name: triage -description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +description: > + Use when the user wants an issue created, triaged, or moved through the + tracker's triage states, or an issue prepared for an AFK agent. Not debugging + the bug itself -> `diagnose`. Not fleshing out a design -> `grill-with-docs`. --- # Triage diff --git a/plugins/bin/.apm/skills/write-docs/SKILL.md b/plugins/bin/.apm/skills/write-docs/SKILL.md index 85289e9..309ec6c 100644 --- a/plugins/bin/.apm/skills/write-docs/SKILL.md +++ b/plugins/bin/.apm/skills/write-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: write-docs -description: Write documentation for X, document this module, create docs for this feature. Use when the user wants to produce or update technical documentation derived from code, spec, or existing artifacts. Do NOT use when the user wants a PRD, ADR, decision doc, or skill file — those have dedicated skills. +description: > + Use when the user wants technical documentation produced or updated from code + or spec, every claim traced to a source. Not a PRD, ADR, or decision doc -> + `grill-with-docs`. Not an external tool researched from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec diff --git a/plugins/bin/skills/grill-me/SKILL.md b/plugins/bin/skills/grill-me/SKILL.md index bd04394..1cc8ede 100644 --- a/plugins/bin/skills/grill-me/SKILL.md +++ b/plugins/bin/skills/grill-me/SKILL.md @@ -1,6 +1,10 @@ --- name: grill-me -description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +description: > + Use when the user says "grill me" or wants a plan or design stress-tested by + relentless interview — one question at a time, down each branch of the + decision tree. Not a plan to challenge against `CONTEXT.md` and ADRs -> + `grill-with-docs`. --- Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. diff --git a/plugins/bin/skills/grill-with-docs/SKILL.md b/plugins/bin/skills/grill-with-docs/SKILL.md index 6dad6ad..8610263 100644 --- a/plugins/bin/skills/grill-with-docs/SKILL.md +++ b/plugins/bin/skills/grill-with-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: grill-with-docs -description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +description: > + Use when a plan should be stress-tested against the project's domain model — + the interview challenges terms against `CONTEXT.md` and writes decisions into + it and into ADRs as they land. Not a plain interview -> `grill-me`. --- <what-to-do> diff --git a/plugins/bin/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/skills/improve-codebase-architecture/SKILL.md index 22d02fb..6ec346b 100644 --- a/plugins/bin/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/skills/improve-codebase-architecture/SKILL.md @@ -1,6 +1,10 @@ --- name: improve-codebase-architecture -description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +description: > + Use when the user wants a codebase's architecture improved — deepening + opportunities that turn shallow modules into deep ones, informed by + `CONTEXT.md` and `docs/adr/`. Not debugging a failure -> `diagnose`. Not + test-first feature work -> `tdd`. --- # Improve Codebase Architecture diff --git a/plugins/bin/skills/prototype/references/ui.md b/plugins/bin/skills/prototype/references/ui.md index 4de17de..9afb121 100644 --- a/plugins/bin/skills/prototype/references/ui.md +++ b/plugins/bin/skills/prototype/references/ui.md @@ -109,3 +109,4 @@ Don't leave variant components or the switcher lying around. They rot fast and c - **Variants that differ only in colour or copy.** That's a tweak, not a prototype. Real variants disagree about structure. - **Sharing too much code between variants.** A shared `<Header>` is fine; a shared `<Layout>` defeats the point. Each variant should be free to throw out the layout. - **Wiring variants to real mutations.** Read-only prototypes are fine. If a variant needs to mutate, point it at a stub — the question is "what should this look like", not "does the backend work". +- **Promoting the prototype directly to production.** The variant code was written under prototype constraints (no tests, minimal error handling). Rewrite it properly when you fold it in. diff --git a/plugins/bin/skills/research/README.md b/plugins/bin/skills/research/README.md index b98c00b..976cd2f 100644 --- a/plugins/bin/skills/research/README.md +++ b/plugins/bin/skills/research/README.md @@ -12,7 +12,7 @@ The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably ## Composition -`references/file-format.md` is not optional reading before the write step: the `sources.md` field names it defines are matched literally by the downstream provenance validator. Prose written in their place parses as nothing and the check passes having verified nothing. +Both reference files are read on condition, never on every run — `SKILL.md` inlines the minimum each step needs (the seven default topic areas at step 1, the four `sources.md` field names and the topic-file frontmatter keys at step 6) and sends the run to the reference only for what it does not carry. Those four field names are matched literally by the downstream provenance validator, so prose written in their place parses as nothing and the check passes having verified nothing — which is why they are inlined rather than deferred. ## Usage @@ -27,5 +27,5 @@ Name the topic and the output path — the skill will stop and ask if the path i | File | Purpose | |------|---------| | `SKILL.md` | The four gotchas and the six research steps | -| `references/topics.md` | Read at Step 1 before narrowing scope: the default topic list (`overview`, `installation`, `configuration`, `cli-reference`, `api-reference`, `examples`, and more) and what each file covers | -| `references/file-format.md` | Read at Step 6 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | +| `references/topics.md` | Read at Step 1 only when what belongs in a default topic is unclear or a custom topic is needed: the per-topic coverage table and the custom-topic naming rule | +| `references/file-format.md` | Read at Step 6 only when the inlined field names do not settle the case: slug derivation, the Context7 slug and URL convention, and what belongs in a topic body | diff --git a/plugins/bin/skills/research/SKILL.md b/plugins/bin/skills/research/SKILL.md index 1779011..19fffee 100644 --- a/plugins/bin/skills/research/SKILL.md +++ b/plugins/bin/skills/research/SKILL.md @@ -30,7 +30,10 @@ model: sonnet Search for existing use of the topic — imports, config files, version pins, reference files already written — and narrow the research to what is missing: the version actually in use, the topics not yet documented. -Read `references/topics.md` before narrowing, for the default topic list. +The default topic areas are `overview`, `installation`, `configuration`, `cli-reference`, +`api-reference`, `examples` and `troubleshooting` — one file each, and only where content exists. +If what belongs in one of them is unclear, or the topic needs a file outside that set, read +`references/topics.md` for the per-topic coverage table and the custom-topic naming rule. ## Step 2 — Resolve against Context7 @@ -56,11 +59,20 @@ If nothing usable comes back, stop and report what was searched, then ask for st ## Step 6 — Write -Merge every set of notes, Context7 and web alike, by topic area. Read `references/file-format.md`, then write, in the output path: +Merge every set of notes, Context7 and web alike, by topic area, then write, in the output path: -- `<topic>.md` for each topic area that has content, default or custom -- `sources.md`, always, one section per source in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` +- `<topic>.md` for each topic area that has content, default or custom. Frontmatter carries `topic:` (the filename without `.md`) and `source_keys:` (kebab-case slugs matching `sources.md`); the body is prose in `##` sections, with no inline URLs. +- `sources.md`, always, one `##` section per source — including sources that yielded nothing — with exactly these four fields: -Spell the `sources.md` field names exactly as `references/file-format.md` gives them. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. + ```markdown + - **URL:** <full URL> + - **Description:** <one-line summary> + - **Contributing files:** <topic files this source contributed to> + - **Status:** `extracted` | `no content extracted` + ``` + +Spell those four field names exactly as given. The downstream provenance validator matches them literally; prose in their place parses as nothing, and the check passes having verified nothing. + +Read `references/file-format.md` when the four fields above do not settle the case: what a slug should be, the `context7-<library-slug>` slug and `context7:<library-id>` URL convention for a Context7 source, or what belongs in a topic body versus a verbatim copy of the source. If no topic area has content, write nothing at all, `sources.md` included, and report what was searched. diff --git a/plugins/bin/skills/tdd/SKILL.md b/plugins/bin/skills/tdd/SKILL.md index 7a98941..d4a97f6 100644 --- a/plugins/bin/skills/tdd/SKILL.md +++ b/plugins/bin/skills/tdd/SKILL.md @@ -1,6 +1,9 @@ --- name: tdd -description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development. +description: > + Use when the user wants a feature built or a bug fixed test-first, in a strict + red-green-refactor loop, one behaviour at a time. Not diagnosing an existing + bug -> `diagnose`. Not throwaway exploratory code -> `prototype`. --- # Test-Driven Development diff --git a/plugins/bin/skills/triage/SKILL.md b/plugins/bin/skills/triage/SKILL.md index 0254fae..d83ab5e 100644 --- a/plugins/bin/skills/triage/SKILL.md +++ b/plugins/bin/skills/triage/SKILL.md @@ -1,6 +1,9 @@ --- name: triage -description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow. +description: > + Use when the user wants an issue created, triaged, or moved through the + tracker's triage states, or an issue prepared for an AFK agent. Not debugging + the bug itself -> `diagnose`. Not fleshing out a design -> `grill-with-docs`. --- # Triage diff --git a/plugins/bin/skills/write-docs/SKILL.md b/plugins/bin/skills/write-docs/SKILL.md index 85289e9..309ec6c 100644 --- a/plugins/bin/skills/write-docs/SKILL.md +++ b/plugins/bin/skills/write-docs/SKILL.md @@ -1,6 +1,9 @@ --- name: write-docs -description: Write documentation for X, document this module, create docs for this feature. Use when the user wants to produce or update technical documentation derived from code, spec, or existing artifacts. Do NOT use when the user wants a PRD, ADR, decision doc, or skill file — those have dedicated skills. +description: > + Use when the user wants technical documentation produced or updated from code + or spec, every claim traced to a source. Not a PRD, ADR, or decision doc -> + `grill-with-docs`. Not an external tool researched from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec -- 2.43.0 From 971e148e19b5d8906d283d57b99c243837342733 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Mon, 31 Aug 2026 19:47:04 +0000 Subject: [PATCH 78/89] docs: amend ADR-0020 and correct the gate reference to match what ships The ADR said a /slash target behind a route verb takes the follower test; the implementation decides notation first and skips it. Recorded as a dated amendment rather than a silent edit, per the ADR-0016/0017 convention. Its Enforcement table called itself exhaustive 'because the failure this ADR is most exposed to is a rule filed under Enforcement that no validator implements'. Three shipped behaviours were missing, including the disable-model-invocation carve-out that removes two rows. Also de-pins the 'zoom-out is the one carrier' claim, which caveman falsified, and the stale dangling-target statuses. gates.md's ERROR row made terminality a conjunct for route notation, telling an author a form is safe that exits 1. It now documents the references/ Vale blind spot and its two independent causes -- AGENTS.md trimmed to the operative rule per the split gates.md itself states -- plus the undocumented skill-frontmatter hook and the second scope exclusion. CONTEXT.md glosses 'routing target'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti --- AGENTS.md | 2 +- CONTEXT.md | 8 ++ ...l-description-and-body-context-contract.md | 113 +++++++++++++++--- docs/spec/gates.md | 94 ++++++++++++++- 4 files changed, 194 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 32fd71d..7826d36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Fall back to raw shell only when no skill covers it. - **Do not add repo-owned keys to `.claude/settings.json`.** apm treats it as its own deployed artifact and `apm audit --ci` replays the install and diffs, so anything apm would not have written is permanent drift that fails the `apm-audit-ci` pre-push hook. A hook you want here is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written into that file. The `SessionStart` entry already in it is exactly that: kyberforge authors it in `plugins/kyberforge/.apm/hooks/hooks.json` and apm merges it in, so it is apm's own output, it is what the replay expects, and it belongs in the commit — do not strip it (ADR-0019). Machine-specific settings go in the gitignored `.claude/settings.local.json`; shared enforcement goes in `.pre-commit-config.yaml`. - **`apm.lock.yaml` turning up modified is expected, not a bug.** kyberforge's `SessionStart` hook runs `apm outdated` at startup and `apm update --yes` when something is behind, which rewrites the lock. Commit or discard it deliberately. - **A `.apm/` edit is not live in this session until it is pushed.** The six dependencies resolve from the holocron remote, unpinned against the default branch. `apm install` deploys from the lock; `apm update` is what re-resolves refs. -- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Retrofitted plugin by plugin under #99 (see `docs/spec/gates.md`). Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and every `references/` file is unlinted — which matters because the contract's own remedy is to move prose *into* `references/`, out of the prose gate's reach. That blind spot has **two** independent causes and closing either alone changes nothing: the `Kyberforge` style is scoped `[**/SKILL.md]` (the cause #117 records), *and* the `vale-audit-prefilter-skill` hook filters on `files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'`, so a reference file is never handed to Vale whatever the style says. Check both gates: `pre-commit run --all-files`. +- **The ADR-0020 skill gates ship hot, with no baseline — and the corpus is now clean.** All 39 skills clear both FAIL tiers: no description over 400 characters, no body over 900 words (counted body-only). Retrofitted plugin by plugin under #99 (see `docs/spec/gates.md`). Because nothing is grandfathered, the gates now bite on first commit — a new skill, or an edit that pushes a description past 400, is blocked until it complies. **No routing target dangles**, and `tests/test-adr0020-targets.sh` pins that set as empty, so a new boundary clause naming a non-existent skill fails the suite rather than joining a backlog. Two blind spots survive: `skill-size-check` does not cover the Vale half, so `Kyberforge.CompositionNote` fires nowhere today but any new description can reintroduce it; and no `references/` file is linted by anything, so prose relocated out of a body to satisfy the word gate lands outside the prose gate. It has two independent causes and closing either alone changes nothing — `docs/spec/gates.md` has both, issue #117 tracks it. Check both gates: `pre-commit run --all-files`. - **Run `bash tests/run-tests.sh --strict` before considering any change done.** Keep the flag: without it a suite whose dependency is missing exits 77 and is counted SKIPPED rather than failed, so the run goes green having verified less than it claims. - **Before pushing, rehearse the gate locally:** `pre-commit run --hook-stage pre-push --all-files`. It runs the 14 pre-push hooks this repo authors itself plus pre-commit's 2 `meta` hooks, so it prints 16; `check-release-needed` passes without checking anything, because it needs a real push to `main`. `docs/spec/gates.md` reconciles both. - **Pushing without a network** needs `SKIP=apm-marketplace-check,apm-pack-check-clean git push` — those two resolve a remote marketplace entry via `git ls-remote`. Skip only those two; the rest are real local checks, and adding one to `SKIP` disarms it silently. diff --git a/CONTEXT.md b/CONTEXT.md index 3e5d366..d9dd395 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,14 @@ trigger clause, at most one capability clause, and a boundary clause, and nothin and the target-resolution walk: `docs/spec/gates.md`. _Avoid_: skill budget, size limit +**Routing target**: +The skill or agent name a boundary clause sends work to. It **resolves** when a skill or agent of +that name is reachable from the file being checked, and **dangles** when none is — a route the router +cannot take. Dangling is a blocking ERROR in route notation (`/name`, `→ name`) and a SUGGESTION for +a bare name nothing else in the sentence corroborates. Verdicts and the resolution walk: +`docs/spec/gates.md`. +_Avoid_: route, pointer, cross-reference + **Dispatch body**: The body pattern a skill with two or more mutually exclusive flows must use — the body carries only the dispatch table and the gates common to every branch, and each flow lives in its own diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md index 19853b4..ef38558 100644 --- a/docs/adr/0020-skill-description-and-body-context-contract.md +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -203,8 +203,14 @@ with no trigger list. Verified end-to-end rather than assumed: `plugins/bin/.apm/skills/zoom-out/SKILL.md:4` carries the flag, apm passes it through verbatim to both `.claude/skills/zoom-out/SKILL.md:4` and the flat mirror -at `plugins/bin/skills/zoom-out/SKILL.md:4`, and `zoom-out` is the one installed skill absent from -the model-visible skill listing in a live session. It remains invocable as `/zoom-out`. +at `plugins/bin/skills/zoom-out/SKILL.md:4`, and `zoom-out` was — at the time of that check, when it +was the only carrier — the one installed skill absent from the model-visible skill listing in a live +session. It remains invocable as `/zoom-out`. `caveman` has since taken the flag as well, so the +corpus now has **two** carriers. Do not read a carrier list off this page; re-derive it: + +``` +grep -l '^disable-model-invocation: true' plugins/*/.apm/skills/*/SKILL.md +``` ### Merging siblings @@ -219,10 +225,13 @@ rather than the core job. skills still exist separately, and this change made the split deeper rather than shallower: retrofit to the dispatch pattern took `skill-audit` from 3 reference files to 7 and `agent-audit` from 4 to 8, and their two same-named `references/description-quality.md` files now differ on 100 of ~120 lines -after normalising `skill`/`agent`, where before they were closer. The merge stays the decision; it -reopens ADR-0008 (agent-audit's single-file invocation contract) and touches every call site in -`skill-author`, `agent-author` and `forge`, which is why it is its own change and not a rider on -this one. Recorded here rather than dropped, so the gap between the rule and the tree is deliberate +after normalising `skill`/`agent`, where before they were closer. It has kept deepening since: the +#99 retrofit added `finding-criteria.md` to `skill-audit`, drawing it level with `agent-audit`. Both +figures move with the next retrofit, so measure rather than quote — +`ls plugins/kyberforge/.apm/skills/<name>/references/ | grep -c '\.md$'`. The merge stays the +decision; it reopens ADR-0008 (agent-audit's single-file invocation contract) and touches every call +site in `skill-author`, `agent-author` and `forge`, which is why it is its own change and not a rider +on this one. Recorded here rather than dropped, so the gap between the rule and the tree is deliberate and dated instead of discovered later. ### Enforcement and rollout @@ -233,11 +242,13 @@ which tier each rule is in, because the failure this ADR is most exposed to is a | Check | Applies to | Tier | Home | |---|---|---|---| -| description characters (250 SUGGESTION / 400 FAIL) | skills, agents | deterministic | `scripts/skill-size-check.sh`; constants mirrored in `skill-audit/scripts/validate.sh` and `agent-audit/scripts/validate.sh` | +| description characters (250 SUGGESTION † / 400 FAIL) | skills, agents | deterministic | `scripts/skill-size-check.sh`; constants mirrored in `skill-audit/scripts/validate.sh` and `agent-audit/scripts/validate.sh` | | body-only words (600 SUGGESTION / 900 FAIL) | skills | deterministic | `skill-size-check.sh`, `skill-audit/scripts/validate.sh` | | description present and non-empty (ERROR) | skills, agents | deterministic | same | -| boundary target resolves to a real skill or agent (ERROR when written as `/name` or `-> name`, or when its own sentence names another target that resolves; SUGGESTION otherwise) | skills, agents | deterministic | same | -| boundary clause absent (SUGGESTION) | skills, agents | deterministic | same | +| boundary target resolves to a real skill or agent (ERROR when written in route notation — `/name`, or any arrow form; or when a *terminal* bare name's own sentence names another target that resolves; SUGGESTION otherwise) | skills, agents | deterministic | same | +| boundary clause absent — `absent` (SUGGESTION) † | skills, agents | deterministic | same | +| an arrow clause is present but no target can be read out of it — `unparsed` (SUGGESTION) † | skills, agents | deterministic | same | +| one arrow clause naming two or more targets, of which only the first is resolved (SUGGESTION, issue #107) † | skills, agents | deterministic | same | | Gotchas entry count over five (SUGGESTION) | skills | deterministic | same | | Gotchas over 25% of the body (SUGGESTION) | skills | deterministic | same | | every `references/<file>.md` a body names exists (ERROR) | skills | deterministic | same | @@ -254,6 +265,21 @@ that guessed at them would be a worse gate than no gate, because it would be bel enforced, they are reviewed, and this table exists so that distinction is written down rather than inferred from whether a validator happens to have been written yet. +**† These four, and only these four, are lifted for a hand-invoked file** — one whose frontmatter +carries `disable-model-invocation: true`, read as a boolean by `hand_invoked()` in all three scripts. +No validator knew the field existed (issue **#108**), so every routing SUGGESTION above fired on +exactly the shape the *Invocation as a design axis* section mandates, and the boundary-clause +remedy — "so the router knows where NOT to send this skill" — was addressed to a router that cannot +see the skill at all. An author who took the advice made the file worse. + +What does **not** lift is the point of the carve-out. Both body word tiers stand: the body is still +loaded on invocation and still competes with the caller's live conversation. The 400-character +description FAIL stands: that description is not preloaded, but it is the one line a user reads when +choosing from the `/` menu, and the ceiling is an outlier stop rather than a routing-quality budget — +which is exactly why the 250-character *target* is the tier that lifts. And a target the description +does happen to name is still resolved and can still dangle as a blocking ERROR. Mechanics, and the +reason the field is read as a boolean rather than as a mention of the key: `docs/spec/gates.md`. + Two of the deterministic rows are tuned for **false positives over recall**, and what they decline to see is part of the contract. On target extraction: a bare hyphenated name counts only inside a boundary sentence, and a single-word name is never matchable bare — `research`, `triage`, `forge`, @@ -264,7 +290,8 @@ raise an error: one followed by an ordinary lowercase noun is a compound **modif confirm-only — it still resolves and still counts as a route when the name exists, but it can never dangle. Only a *terminal* target can. The compressed arrow form `→ <name>` is exempt from that follower test and is always error-eligible, because nothing reads as a compound modifier after an -arrow; a `/slash` target reached through a route verb is **not** exempt and takes the same test. The +arrow; a `/slash` target reached through a route verb is **not** exempt and takes the same test. +*Amended 2026-08-31 — the `/slash` half is reversed: it is exempt too. See the amendment below.* The simpler rule — "only marked targets may dangle" — was available and would have been wrong here: both live true positives are bare, `research`'s "(use neuledge-context)" and the `gitea-labels-` / `milestones` fold. On the body-shape checks: a `## Gotchas` heading must *end* in "gotchas", not @@ -296,6 +323,46 @@ Three pre-existing contradictions are fixed in the same change, because they are - `description-quality.md:45-50` has no FAIL condition for internal-mechanics content, which is why `skill-author/SKILL.md:102` never bit. +## Amendment (2026-08-31): route notation short-circuits the follower test, `/name` included + +The Enforcement section above exempts the arrow form from the follower test and then withholds the +same exemption from `/name`: "a `/slash` target reached through a route verb is **not** exempt and +takes the same test." That half is reversed. **Both spellings of route notation are exempt, and the +exemption is decided before the follower test rather than weighed against it.** + +Three things make the original call wrong rather than merely strict. + +**It contradicted the promise the same paragraph makes.** Route notation is offered to an author as +the way to get a target checked unconditionally — the SUGGESTION text on an unpromoted target says +so in as many words: "write it as `/name` or `-> name` and it will be checked properly." Under the +original rule that was true of one of the two spellings. `-> name` reached `_add()` with +`strict=True` from both its call sites; `/name` did not, so it fell through to `_terminal()` and any +follower outside `FOLLOWER_OK` demoted it. `Do not use for Y — use /no-such-skill afterwards.` exited +0 — and, before the companion visibility fix, in total silence. + +**The follower test's own justification does not reach `/name`.** That test exists for *prose*: a +bare hyphenated token followed by an ordinary lowercase noun is a compound modifier, "pre-commit +hooks" and "pull-request template". A leading slash is Claude Code's invocation syntax and occurs in +no English compound, so there is no attributive reading to protect. The exemption was withheld from +the one shape the rule it protects against cannot describe. + +**`FOLLOWER_OK` is a closed whitelist of roughly eighty words, and a closed list is the wrong thing +to hang a blocking gate on.** Leaving `/name` under it made *whether a commit is blocked* depend on +whether someone had thought to enumerate the next word — the gate failing open on its own +unfamiliarity. The bare-target path keeps the follower test precisely because it needs a brake it can +justify; the notation path asked for one and was given the same brake by accident. + +What is unchanged: the **corroboration** branch. A *bare* terminal name still earns its blocking +ERROR only from a resolving sibling in the same sentence, and a compound modifier still cannot +dangle at all. The conservative tuning that decision rests on is untouched — this amendment moves one +explicitly-marked spelling out from under it, not the prose path. + +Verified on fixtures inside a synthetic plugin tree: `… Do not use for Y — use /no-such-skill +afterwards.` exits 1, while the same sentence with the bare `no-such-skill` exits 0 at SUGGESTION, +and rises to a blocking ERROR the moment a resolving sibling joins it. The reasoning is recorded at +the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two +mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape. + ## Consequences **Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39 @@ -342,8 +409,8 @@ and `git-*` families — where every sibling shares a keyword and boundary claus — are the ones most likely to sit at the FAIL tier permanently. If the retrofit shows that family routing degrades, the tier is the first thing to revisit. -**Four broken routing targets were found; two are fixed here and two are live.** Tracked as issue -#100. +**Four broken routing targets were found; two were fixed here and two shortly after.** Tracked as +issue #100. - `skill-audit` routed to `/skill-improve` twice in its description plus `README.md:10`, and no such skill exists — the real target is `skill-author`. **Fixed here**, as a side effect of retrofitting @@ -353,14 +420,24 @@ routing degrades, the tier is the first thing to revisit. detectable by the resolvable-target check and never will be: "examine agent files manually" names no target, and a check that resolves names cannot see a name that is absent. A misroute to nowhere is a review finding, not a gate finding. -- `research` routes to `neuledge-context`, which exists only inside that string. **Live.** +- `research` routes to `neuledge-context`, which exists only inside that string. Was **live**; + **fixed under #99** — the retrofitted description names no such target. - `gitea-issues` carries the literal string `gitea-labels- milestones` in its folded description, a - stray space introduced by YAML wrapping mid-token, breaking the skill name in preloaded text. - **Live** — the check reports it as a dangling `gitea-labels`. + stray space introduced by YAML wrapping mid-token, breaking the skill name in preloaded text. Was + **live**, reported as a dangling `gitea-labels`; **fixed under #99** — the name now folds intact. -So the check fires on 3 of the 4 against the base commit and on 2 at the tip of this change, and -`tests/test-skill-size-check.sh` probes exactly those three by name rather than asserting a count, so -it degrades to SKIP as #100 lands rather than going stale. +So the check fired on 3 of the 4 against the base commit and on 2 at the tip of the change that +carried this ADR. **The corpus dangling set is now empty**, and that is asserted rather than +observed: `tests/test-adr0020-targets.sh` pins the set as empty, so a new boundary clause naming a +non-existent skill fails the suite instead of joining a backlog. `tests/test-skill-size-check.sh` +probed the three original names rather than asserting a count; as each was retrofitted its probe was +**removed, not skipped**, because a `pass "SKIP: …"` branch is an assertion-free result counted in +the totals and makes the suite look one test stronger than it is. That file's commentary survives the +probes and states the rule. Re-derive the current set — never read it off this page: + +``` +bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md | grep 'does not resolve' +``` **Duplication between `skill-author` and `agent-author` survives un-gated.** The merge rule deliberately excludes the author pair, so the commit-verification argument in four near-copies, the diff --git a/docs/spec/gates.md b/docs/spec/gates.md index c00048a..cdd1e3c 100644 --- a/docs/spec/gates.md +++ b/docs/spec/gates.md @@ -98,11 +98,57 @@ loudly (`Error: jq is required but not installed`). ## Skill and agent context gates (ADR-0020) The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`, -runs `scripts/skill-size-check.sh`. That scope means it never lints the -`plugins/kyberforge/docs/research/examples/` reference skills. It is also shipped to external repos -as `kyberforge-skill-size-check` (see +runs `scripts/skill-size-check.sh`. It is also shipped to external repos as +`kyberforge-skill-size-check` (see [External consumers](#external-consumers-the-root-pre-commit-hooksyaml)). +**Two things fall outside that scope, both deliberately.** The `[^/]+/SKILL\.md$` tail admits only a +`SKILL.md` sitting directly in a skill directory under `.apm/skills/`: + +- the `plugins/kyberforge/docs/research/examples/` reference skills, which are vendored upstream + corpus and not this repo's to gate; +- `plugins/kyberforge/.apm/skills/skill-author/assets/templates/SKILL.md` — inside `.apm/skills/`, + but two directories deeper. It is the `FILL IN:` scaffold `skill-author` copies, so its + `description: >` is a comment block rather than a description and every ADR-0020 measurement over + it would be meaningless. A reader adjusting the pattern needs to know it is there. + +Everything else it matches exactly, with nothing over- or under-caught. Re-derive both halves: + +``` +git ls-files | grep -cE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the real skills +git ls-files | grep -E '^plugins/[^/]+/\.apm/skills/.*SKILL\.md$' \ + | grep -vE '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' # the scaffold only +``` + +The first count equals the number of skill directories (`ls -d plugins/*/.apm/skills/*/ | wc -l`); +the second returns exactly the template. The remaining unmatched `SKILL.md` files in the tree are the +generated flat mirror, which is excluded by the `.apm/` segment on purpose — a mirror edit is drift, +not an authoring change. + +### `skill-frontmatter`, the other hook on that scope + +A second `repo: local` pre-commit hook, `skill-frontmatter`, runs on the **same** `files:` pattern at +the same stage. It is a short shell loop: for each file, `grep -q "^name:"` and +`grep -q "^description:"`, failing with "missing required frontmatter fields" if either is absent. + +**It overlaps ADR-0020's "description present and non-empty" FAIL, and the overlap is not clean.** +The ADR (`:95-101`) requires that question be decided on the **YAML-folded value** and nowhere else, +precisely because a line regex gets it wrong in both directions. Measured on fixtures: + +| Frontmatter | `skill-frontmatter` | `skill-size-check` | +|---|---|---| +| `description:` with no value, then `model: sonnet` | passes — the key is on a line | ERROR, "missing or empty" | +| `"description": …` (quoted key, valid YAML) | **fails** — `^description:` does not match | passes, description read normally | + +So the grep is not a second opinion on presence. It is blind to the shape ADR-0020 was written +against, and it is the only one of the two that objects to a quoted key. Neither disagreement is +currently live in the corpus, and the honest reading is that presence is `skill-size-check`'s +question — the grep's contribution to it is noise on one shape and silence on the other. + +What the grep does add is the `name:` key, which **no** ADR-0020 check reads: a `SKILL.md` with no +`name:` passes `skill-size-check` at exit 0. That is its real and only unique coverage, and the +reason not to fold it into the size gate on the grounds of redundancy. + ### Two independent gate families, neither replaced the other **Family 1 — agentskills.io spec backstop** (unchanged, conformance not quality): @@ -141,7 +187,7 @@ A boundary-clause target that resolves to no skill or agent has **three** possib | Verdict | When | |---|---| | **SUGGESTION** — the default | the target does not resolve and neither promotion condition below holds | -| **blocking ERROR** | the target is **terminal** (not a compound modifier) **and** either written in route notation (`/name` for any name; a bare `-> name` only when the name is hyphenated, a backticked `` -> `name` `` for any — see the gap below) **or** corroborated by another target in the same sentence that *does* resolve | +| **blocking ERROR** | the target is written in **route notation** — `/name` for any name, or any arrow form (a bare `-> name` only when the name is hyphenated, a backticked `` -> `name` `` for any — see the gap below); **or** it is a bare **terminal** name (not a compound modifier) **corroborated** by another target in the same sentence that *does* resolve | | **INFO, "DID NOT RUN"** | no skill universe could be determined for the path at all — the targets are named and left unchecked, exit 0 | The default is deliberately soft because a hyphenated word in a boundary clause is as likely to be a @@ -149,6 +195,17 @@ tool, a file format or an English compound as a route: "pre-commit hooks" is pro never reaches the check at all, being a compound modifier rather than a terminal name. The SUGGESTION text says how to opt in — write it as `/name` or `-> name` and it gets checked properly. +**The two promotion conditions are not symmetric, and the order matters.** `_add()` decides +**notation first**: when the name is written `/name`, or reached through any arrow form, the target +is marked error-eligible there and the terminal test is never run. Terminality gates only the *bare* +path — a name in prose earns its error from corroboration, and a compound modifier can never dangle. +Reading the row as "terminal AND (notation OR corroborated)" gets the notation half backwards: it +predicts that `` … Do not use for Y — use /no-such-skill afterwards. `` is a SUGGESTION, because +`afterwards` is a follower outside `FOLLOWER_OK`. It exits 1. That was the defect — `-> name` reached +`_add()` with `strict=True` from both its call sites and `/name` did not, so the one spelling +ADR-0020 offers an author who wants a route checked unconditionally was the one spelling a stray +follower could silence. + **Known gap: a BARE arrow target must be hyphenated.** Target extraction is built on `NAME_HYPH` in `scripts/skill-size-check.sh`, which requires at least one hyphen, and `ARROW_BOUNDARY` inherits that. So `Not X -> gitea-prs` is extracted and checked, while `Not X -> triage` yields no target. @@ -558,6 +615,35 @@ passing one explicit file per invocation. The two manifests scope **differently Narrowing a `.vale.ini` glob to a `plugins/`-shaped path to "tighten" it breaks the consumer case, and `check-vale-style-sync`'s probe set is built to catch exactly that. +### The blind spot: `references/` is unlinted, for two independent reasons + +Every `references/*.md` file in the corpus is outside the prose gate. Count them with +`git ls-files | grep -cE '^plugins/[^/]+/\.apm/skills/[^/]+/references/.*\.md$'` rather than reading +a figure here; it moves with every retrofit. This is the gap that matters most, because the context +contract's own remedy for an over-long body is to move prose **into** `references/` — the gate pushes +text across its own boundary and then stops watching it. + +**Closing either cause alone changes nothing.** There are two, and they are independent: + +| Cause | Where | Effect on a `references/` file | +|---|---|---| +| the `Kyberforge` style is scoped `[**/SKILL.md]` | `skill-audit/assets/vale/.vale.ini` | matches no section, so Vale lints 0 files and exits 0 | +| the hook's `files:` regex is `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` | `vale-audit-prefilter-skill` in `.pre-commit-config.yaml` | the file is never handed to Vale at all | + +Verified both ways. Handing skill-audit's `vale-wrap.sh` a reference file directly — bypassing +pre-commit entirely, so only the style scope is in play — prints `0 errors … in 0 files` and exits 0, +where the same wrapper on a `SKILL.md` reports `in 1 file`. And the hook's `files:` regex, applied to +`git ls-files`, selects only the skill-directory `SKILL.md` files scoped at the top of this page, so +pre-commit never hands Vale a reference file to begin with. Widening the glob to `[**/*.md]` would +still lint nothing through the hook; widening the hook's `files:` alone would hand Vale files its own +config declines to match, which is the [0-file NOT RUN](#a-0-file-vale-run-is-not-run) shape — a +green run that measured nothing. **Issue #117** records the style-scope half; the hook half has to +land in the same change or the fix is cosmetic. + +The consumer manifest is a third axis and does not rescue this either: `.pre-commit-hooks.yaml`'s +`(^|/)SKILL\.md$` is layout-agnostic but still filename-shaped, so an external repo running +`kyberforge-vale-audit-skill` has the same gap. + ### `vale-wrap.sh`, never bare `vale` Both audit skills' Step 1 and both pre-commit hooks call **each copy's own** -- 2.43.0 From 9fe734573de3bc9834fe5a698659e6ccbfb342b1 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:37:12 +0000 Subject: [PATCH 79/89] fix(gates): close the /name fail-open and stop the path guard inventing targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the routing-target resolver, both latent in the corpus but hot for anything written next. The free-standing `/name` sweep sat inside `if boundary:`, so route notation in a sentence carrying no boundary marker was never extracted at all — not an ERROR, not a SUGGESTION, not an INFO. That contradicted ADR-0020's amendment and gates.md, which both promise `/name` blocks unconditionally. The sweep now runs over every sentence. `-> name` and backticked forms stay gated deliberately: an arrow also writes a process chain and a code span cites tools, files and skills alike, so ungating either fires on ordinary prose. The path guard used `\b`, which still holds after a hyphen, so the engine backtracked to a shorter hyphen-terminated prefix whenever the lookahead rejected the full segment. `/api-docs/v2.md` in a boundary clause raised blocking ERRORs for 'api' and 'api-docs' — names no author wrote, with no corroboration escape. `(?![\w-])` forbids the shortened prefix outright; MARKED_TARGET, which had no trailing guard at all, gained one. Zero arguments now exits 2 rather than 0, so a mis-scoped `files:` pattern is no longer indistinguishable from a clean corpus. Both hook manifests pass filenames and pre-commit skips a filename-passing hook when nothing matches, so the hook never sees an empty argv — that contract is now asserted by a test rather than left in prose. Deleting the sweep entirely used to leave every suite green. It now kills eight assertions. The suite also gains its first slash-path and URL fixtures, in both directions. Refs: #107, #110, #124 ADR: 0020 --- .../skills/agent-audit/scripts/validate.sh | 100 +++++++--- .../skills/agent-audit/tests/validate.bats | 20 ++ .../skills/skill-audit/scripts/validate.sh | 100 +++++++--- .../skills/skill-audit/tests/validate.bats | 30 +++ .../skills/agent-audit/scripts/validate.sh | 100 +++++++--- .../skills/skill-audit/scripts/validate.sh | 100 +++++++--- scripts/skill-size-check.sh | 185 ++++++++++++------ tests/test-adr0020-targets.sh | 144 ++++++++++++++ tests/test-skill-size-check.sh | 169 ++++++++++++++++ 9 files changed, 802 insertions(+), 146 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index 46319f4..738ab4e 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -69,6 +69,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] @@ -552,9 +570,9 @@ 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 inside a boundary sentence, 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 +# 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: @@ -565,6 +583,7 @@ def known_targets(start_dir): # 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 @@ -573,12 +592,43 @@ def known_targets(start_dir): # visible SUGGESTION into silence — the gate teaching the one edit that blinds # it. # -# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate -# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# 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 @@ -590,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) @@ -603,10 +654,12 @@ 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) -# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their -# caller. 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 the -# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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 @@ -618,7 +671,8 @@ ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) # 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)\b(?!/|\.\S)" % NAME_ANY, re.I) +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 @@ -794,14 +848,16 @@ 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: - # Route notation wherever it sits in the clause, not only where a route - # verb or an arrow happens to precede it. See the EXPLICIT ROUTE - # NOTATION note in the header for the seven phrasings this recovers and - # for why silence was the failure mode. Neither sweep takes the follower - # test: _add() reads the notation first and both forms reach it marked. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) + # 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) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats index b58033e..9839841 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats @@ -943,3 +943,23 @@ EOF refute_output --partial "Traceback" refute_output --partial "FileNotFoundError" } + +# --------------------------------------------------------------------------- +# Encoding, write side: sys.stdout/stderr.reconfigure(encoding='utf-8') +# +# read_text() in the shared resolver block pins the READS to UTF-8. That moved +# the LC_ALL=C crash to the WRITE: this script's own message text carries em +# dashes (the ADR-0020 boundary SUGGESTION is one), so the streams' ASCII +# default raised UnicodeEncodeError while PRINTING — after every check had +# already run. Here it also flipped a clean exit 0 into a traceback and exit 1. +# --------------------------------------------------------------------------- + +@test "under LC_ALL=C the report is printed, not lost to a UnicodeEncodeError" { + local root="$TMPDIR/locale-pkg" + make_apm_agent "$root" "locale-agent" + run env LC_ALL=C PYTHONUTF8=0 bash "$SCRIPT" "$root/.apm/agents/locale-agent.agent.md" + assert_success + assert_output --partial "description has no boundary clause" + refute_output --partial "UnicodeEncodeError" + refute_output --partial "Traceback" +} diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index 8934687..e5ce8f3 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -59,6 +59,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") @@ -478,9 +496,9 @@ 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 inside a boundary sentence, 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 +# 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: @@ -491,6 +509,7 @@ def known_targets(start_dir): # 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 @@ -499,12 +518,43 @@ def known_targets(start_dir): # visible SUGGESTION into silence — the gate teaching the one edit that blinds # it. # -# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate -# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# 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 @@ -516,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) @@ -529,10 +580,12 @@ 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) -# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their -# caller. 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 the -# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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 @@ -544,7 +597,8 @@ ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) # 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)\b(?!/|\.\S)" % NAME_ANY, re.I) +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 @@ -720,14 +774,16 @@ 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: - # Route notation wherever it sits in the clause, not only where a route - # verb or an arrow happens to precede it. See the EXPLICIT ROUTE - # NOTATION note in the header for the seven phrasings this recovers and - # for why silence was the failure mode. Neither sweep takes the follower - # test: _add() reads the notation first and both forms reach it marked. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) + # 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) diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats index d41c364..3740033 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats @@ -743,3 +743,33 @@ make_hand_invoked_skill() { 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" +} diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh index 46319f4..738ab4e 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate.sh @@ -69,6 +69,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] @@ -552,9 +570,9 @@ 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 inside a boundary sentence, 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 +# 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: @@ -565,6 +583,7 @@ def known_targets(start_dir): # 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 @@ -573,12 +592,43 @@ def known_targets(start_dir): # visible SUGGESTION into silence — the gate teaching the one edit that blinds # it. # -# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate -# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# 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 @@ -590,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) @@ -603,10 +654,12 @@ 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) -# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their -# caller. 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 the -# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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 @@ -618,7 +671,8 @@ ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) # 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)\b(?!/|\.\S)" % NAME_ANY, re.I) +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 @@ -794,14 +848,16 @@ 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: - # Route notation wherever it sits in the clause, not only where a route - # verb or an arrow happens to precede it. See the EXPLICIT ROUTE - # NOTATION note in the header for the seven phrasings this recovers and - # for why silence was the failure mode. Neither sweep takes the follower - # test: _add() reads the notation first and both forms reach it marked. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) + # 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) diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh index 8934687..e5ce8f3 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate.sh @@ -59,6 +59,24 @@ import glob import yaml +# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams +# default to ASCII, and this script's own message text carries em dashes (the +# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash +# from the read to the write — a UnicodeEncodeError raised while PRINTING, after +# every check has already run, which loses the whole report and (here) flips a +# clean exit 0 into a traceback and an exit 1. read_text() in the shared +# resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two +# validate.sh copies print findings, skill-size-check.sh has its own top-level +# equivalent, and tests/test-adr0020-contract.sh hashes that block for +# byte-identity across all three. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") @@ -478,9 +496,9 @@ 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 inside a boundary sentence, 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 +# 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: @@ -491,6 +509,7 @@ def known_targets(start_dir): # 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 @@ -499,12 +518,43 @@ def known_targets(start_dir): # visible SUGGESTION into silence — the gate teaching the one edit that blinds # it. # -# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate -# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# 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 @@ -516,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) @@ -529,10 +580,12 @@ 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) -# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their -# caller. 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 the -# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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 @@ -544,7 +597,8 @@ ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) # 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)\b(?!/|\.\S)" % NAME_ANY, re.I) +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 @@ -720,14 +774,16 @@ 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: - # Route notation wherever it sits in the clause, not only where a route - # verb or an arrow happens to precede it. See the EXPLICIT ROUTE - # NOTATION note in the header for the seven phrasings this recovers and - # for why silence was the failure mode. Neither sweep takes the follower - # test: _add() reads the notation first and both forms reach it marked. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) + # 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) diff --git a/scripts/skill-size-check.sh b/scripts/skill-size-check.sh index 619b9ed..36828c1 100755 --- a/scripts/skill-size-check.sh +++ b/scripts/skill-size-check.sh @@ -106,40 +106,52 @@ BODY_MAX_WORDS=900 FAIL=0 -for f in "$@"; do - # NOT a silent skip — see the matching note on the Python side. A broken - # symlink named SKILL.md is storable in git and a directory named SKILL.md - # reaches this hook the same way; both used to make the whole run exit 0 with - # no output at all, which is the one thing this script must never do. - if [[ ! -f "$f" ]]; then - if [[ -d "$f" ]]; then - why="is a directory, not a file" - elif [[ -L "$f" ]]; then - why="is a symlink that does not resolve to a file" - elif [[ -e "$f" ]]; then - why="is not a regular file" - else - why="does not exist" - fi - echo "ERROR: $f $why, so the line and word ceilings could not be measured. A path this hook was handed and could not read does not get to pass in silence." >&2 - FAIL=1 - continue - fi +# ZERO ARGUMENTS IS A USAGE ERROR, exit 2 — not a clean run. +# +# This hook is `pass_filenames: true` in both .pre-commit-config.yaml and +# .pre-commit-hooks.yaml, and pre-commit skips a filename-passing hook entirely +# when nothing matches its `files:` pattern, so it never invokes this script +# with an empty argument list. Every no-argument invocation therefore comes from +# somewhere else — a hand-run command, a wrapper, or a `files:` pattern edited +# into matching nothing — and printing nothing and exiting 0 made all three +# indistinguishable from a clean corpus. A mis-scoped pattern would have +# silently disabled the whole ADR-0020 gate family while every hook reported +# green. +# +# Exit 2, not 1, for the same reason a8cd5e8 split validate-adapter.sh's usage +# exits out: {0,1} are this script's verdict codes (clean / findings), and a +# caller that reads a non-zero exit as "the SKILL.md needs editing" must be able +# to tell a broken invocation from a real finding. +if [[ $# -eq 0 ]]; then + echo "usage: skill-size-check.sh <SKILL.md> [SKILL.md ...]" >&2 + echo " Measures the agentskills.io spec ceilings and the ADR-0020 context" >&2 + echo " budget for each SKILL.md named on the command line." >&2 + echo " No paths were given. This is a usage error, not a clean run: a hook" >&2 + echo " whose files: pattern matches nothing would otherwise be" >&2 + echo " indistinguishable from a corpus with no findings." >&2 + exit 2 +fi - # The MAX_LINES / MAX_WORDS ceilings are NOT measured here. They used to be, - # in a single awk pass, and that pass was wrong twice over: - # * `read -r lines words <<< "$(awk ...)"` discarded awk's exit status, so a - # file awk could not read yielded empty variables, bash arithmetic read - # them as 0, and both ceilings passed in total silence — the one outcome - # this script forbids itself. - # * awk's NR/NF do not agree with the Python splitlines()/split() that - # skill-audit/scripts/validate.sh uses for the SAME two constants. - # splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029 - # and split() on every Unicode space, so a body padded with U+2028 read as - # 6 lines here and 606 lines there — hook green, audit FAIL. - # One implementation now owns both: the Python block below already reads every - # file (with a real diagnostic on failure), so it counts there. -done +# An unreadable path — a broken symlink named SKILL.md is storable in git, and a +# directory named SKILL.md reaches this hook the same way — is diagnosed ONCE, +# in the Python per-file loop below. There used to be a bash pre-loop here doing +# exactly the same stat dance and printing exactly the same sentence, so every +# such path was reported twice with two ERROR lines for one broken file. It is +# still NOT a silent skip; the diagnosis simply lives where the file is read. +# +# The MAX_LINES / MAX_WORDS ceilings are not measured in bash either. They used +# to be, in a single awk pass, and that pass was wrong twice over: +# * `read -r lines words <<< "$(awk ...)"` discarded awk's exit status, so a +# file awk could not read yielded empty variables, bash arithmetic read +# them as 0, and both ceilings passed in total silence — the one outcome +# this script forbids itself. +# * awk's NR/NF do not agree with the Python splitlines()/split() that +# skill-audit/scripts/validate.sh uses for the SAME two constants. +# splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029 +# and split() on every Unicode space, so a body padded with U+2028 read as +# 6 lines here and 606 lines there — hook green, audit FAIL. +# One implementation now owns both: the Python block below already reads every +# file (with a real diagnostic on failure), so it counts there. if ! command -v python3 > /dev/null 2>&1; then echo "ERROR: python3 is required for the ADR-0020 description/body/boundary-target gates but was not found on PATH." >&2 @@ -165,6 +177,23 @@ import sys 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. read_text() in the +# shared resolver block below pins the reads; this pins the writes. +# +# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: all three +# scripts in the family need this, but tests/test-adr0020-contract.sh hashes +# that block for byte-identity, so shared-looking edits belong beside it, not +# inside it. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding='utf-8') + except AttributeError: # pragma: no cover — Python < 3.7 + pass + DESC_SUGGEST_CHARS = int(sys.argv[1]) DESC_MAX_CHARS = int(sys.argv[2]) BODY_SUGGEST_WORDS = int(sys.argv[3]) @@ -580,9 +609,9 @@ 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 inside a boundary sentence, 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 +# 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: @@ -593,6 +622,7 @@ def known_targets(start_dir): # 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 @@ -601,12 +631,43 @@ def known_targets(start_dir): # visible SUGGESTION into silence — the gate teaching the one edit that blinds # it. # -# The sweeps are gated on the sentence carrying a BOUNDARY_MARKER, the same gate -# the backtick sweep uses, and NOTATION_SLASH refuses 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. +# 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 @@ -618,7 +679,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) @@ -631,10 +693,12 @@ 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) -# The two EXPLICIT ROUTE NOTATION sweeps, scoped to a boundary sentence by their -# caller. 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 the -# trailing lookahead here is the part that makes a FREE-STANDING sweep safe. +# 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 @@ -646,7 +710,8 @@ ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) # 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)\b(?!/|\.\S)" % NAME_ANY, re.I) +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 @@ -822,14 +887,16 @@ 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: - # Route notation wherever it sits in the clause, not only where a route - # verb or an arrow happens to precede it. See the EXPLICIT ROUTE - # NOTATION note in the header for the seven phrasings this recovers and - # for why silence was the failure mode. Neither sweep takes the follower - # test: _add() reads the notation first and both forms reach it marked. - for match in NOTATION_SLASH.finditer(sentence): - _add(out, sentence, match.group(1), match.start(1), match.end(1)) + # 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) @@ -1228,7 +1295,9 @@ for path in files: # so pre-commit really can hand one to this hook, and a directory named # SKILL.md reaches it the same way — both used to exit 0 with zero # output, which is precisely the "stay quiet about a measurement it did - # not take" failure this script forbids itself two screens up. + # not take" failure this script forbids itself two screens up. This is + # the ONLY place that diagnosis is made; the bash pre-loop that used to + # duplicate it printed a second ERROR line for the same broken file. if os.path.isdir(path): why = "is a directory, not a file" elif os.path.islink(path): @@ -1250,8 +1319,8 @@ for path in files: # SPEC CONFORMANCE (family 1). Whole file, frontmatter included, counted # with the SAME primitives skill-audit/scripts/validate.sh uses for these - # two constants — see the note in the bash loop above for what the previous - # awk pass got wrong. + # two constants — see the note in bash above for what the previous awk pass + # got wrong. lines = len(raw.splitlines()) words = len(raw.split()) if lines > MAX_LINES: diff --git a/tests/test-adr0020-targets.sh b/tests/test-adr0020-targets.sh index 3247ee5..cd1f20a 100755 --- a/tests/test-adr0020-targets.sh +++ b/tests/test-adr0020-targets.sh @@ -780,6 +780,150 @@ else fail "a populated skills/ghost-skill/ directory still did not resolve (exit $GHOST_RC): ${GHOST_OUT:-<empty>}" fi +# --------------------------------------------------------------------------- +# 2g. The FREE-STANDING /name sweep, and its reach beyond a boundary sentence +# --------------------------------------------------------------------------- +# NOTATION_SLASH's own sweep in _extract_sentence() is what sees `/name` when no +# route verb and no arrow precedes it. Nothing pinned it: every `/name` fixture +# in this suite before these cases ALSO carried a route verb ("use +# /no-such-slash-skill instead"), which ROUTE_ANY/ROUTE_MARKED extract on their +# own, so deleting the sweep outright left the whole suite green. The eight +# phrasings below carry no route verb in front of the target, so each of them is +# invisible without the sweep — which is exactly the silence the sweep exists to +# repair, and the shape the SUGGESTION tier's own remedy ("write it as `/name` +# and it will be checked properly") used to teach an author to produce. +echo "" +echo "--- /name with no route verb in front of it is still extracted ---" +grammar_case sweep-dash errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — /no-such-skill instead." +grammar_case sweep-semicolon errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y; /no-such-skill handles that." +grammar_case sweep-paren errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y (/no-such-skill covers it)." +grammar_case sweep-possessive errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — that is /no-such-skill's job." +grammar_case sweep-defer errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — defer to /no-such-skill." +grammar_case sweep-terminal errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — /no-such-skill." +# The arrow twin. `;` ends CLAUSE_BODY, so ARROW_BOUNDARY cannot reach across it +# from `not`; only NOTATION_ARROW's own sweep sees this one. +grammar_case sweep-arrow-after-semicolon errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y; -> no-such-skill covers it." + +# THE SWEEP IS NOT SCOPED TO A BOUNDARY SENTENCE, and this is the case that +# proves it. Extraction is per-sentence (corroboration is scoped to one +# sentence), so gating the `/name` sweep on the sentence carrying a +# BOUNDARY_MARKER meant a route written one sentence AFTER the boundary clause +# was never looked at: exit 0, no ERROR, no SUGGESTION, not even the name. That +# contradicts ADR-0020's amendment and docs/spec/gates.md, which both promise +# `/name` blocks unconditionally, for any name. +# +# The first sentence's `/sibling-skill` is deliberate: it resolves, so the +# fixture is not "the gate fires on any slash it sees" — it fires on the one +# that dangles, in the sentence that carries no boundary marker at all. +echo "" +echo "--- /name is checked in a sentence that carries no boundary marker ---" +grammar_case sweep-outside-boundary errors "routes to 'no-such-skill'" \ + "Use for X. Do not use for Z — use /sibling-skill instead. For W, /no-such-skill is the right entry point." +# Control, so the case above is not satisfied by a gate that fires on every +# unresolvable-looking token in a non-boundary sentence: the same shape with a +# name that RESOLVES stays silent. +grammar_case sweep-outside-boundary-control silent "" \ + "Use for X. Do not use for Z — use /sibling-skill instead. For W, /sibling-skill is the right entry point." + +# --------------------------------------------------------------------------- +# 2h. A slash PATH is not a route (the trailing guard, and its backtracking) +# --------------------------------------------------------------------------- +# There was no path or URL fixture anywhere in this suite, and the guard was +# defeated by ordinary regex backtracking. `/(NAME_ANY)\b(?!/|\.\S)` looks like +# it refuses a path, and does not: when the lookahead rejects the FULL segment +# the engine backtracks to a shorter hyphen-terminated prefix, `\b` still holds +# after a hyphen, and the phantom is reported as a hard BLOCKING ERROR naming a +# skill nobody wrote: +# /opt-tools/bin/thing -> ERROR: routes to 'opt' +# /api-docs/v2.md -> ERROR: routes to 'api' AND to 'api-docs' +# /no-such-skill.md -> ERROR: routes to 'no-such' +# `(?![\w-])` is the guard that actually holds, because it forbids the shortened +# prefix instead of merely disliking the full one. MARKED_TARGET carries it too: +# that pattern had NO trailing lookahead at all, which is where the second +# 'api-docs' error above came from. +# +# These are `silent`, not `suggests`. A path is not a routing target at any +# tier — reporting one would be the same false positive one notch quieter, on +# the skills most likely to name a path in a boundary clause. +echo "" +echo "--- a slash PATH in a boundary sentence is not a routing target ---" +grammar_case path-absolute silent "" \ + "Use when doing the thing. Do not use for Y; the config lives at /opt-tools/bin/thing." +grammar_case path-dotted-file silent "" \ + "Use when doing the thing. Do not use for Y — see /api-docs/v2.md for the schema." +grammar_case path-dotted-backticked silent "" \ + "Use when doing the thing. Do not use for Y — see \`/api-docs/v2.md\` for the schema." +grammar_case path-md-suffix silent "" \ + "Use when doing the thing. Do not use for Y — the file /no-such-skill.md documents it." +# The two suppressions that were already working and must keep working: a URL +# (the `/` is preceded by a word character or by another `/`) and a relative +# references/ pointer. Asserted explicitly because the guard above is a change to +# the same lookarounds, and a fix that traded one silence for another would look +# identical from the corpus. +grammar_case path-url silent "" \ + "Use when doing the thing. Do not use for Y — see https://example.com/no-such-skill for details." +grammar_case path-relative silent "" \ + "Use when doing the thing. Do not use for Y — see references/no-such-skill.md for details." +# The other direction, which is what stops the guard from becoming a hole: a +# name whose only follower is the SENTENCE-ENDING dot is still a route. A +# closing `.` is not followed by a non-space, so `(?!\.\S)` does not reject it. +# Without these, "refuse every /name near a dot or a slash" would pass every +# case above and silently delete the notation tier. +echo "" +echo "--- the path guard does not swallow a /name at a real sentence end ---" +grammar_case path-guard-sentence-end errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — defer to /no-such-skill." +grammar_case path-guard-mid-sentence errors "routes to 'no-such-skill'" \ + "Use when doing the thing. Do not use for Y — use /no-such-skill for that instead." + +# --------------------------------------------------------------------------- +# 2i. A DIRECTORY named <something>.md is not an agent +# --------------------------------------------------------------------------- +# The skills branch of _collect_package() tests for a SKILL.md; the agents +# branch takes every `*.md` glob hit on trust, and glob does not distinguish a +# file from a directory. A leftover directory named `ghost-agent.md` — a botched +# `mkdir`, an editor's stray save, a half-deleted agent — is untracked by git, so +# it exists on the machine that made it and nowhere else, and it resolved a +# routing target there and dangled everywhere else. That is exactly the +# install-dependence fixture 2f pins one directory over, and the isfile() guard +# closing it had no test at all: deleting it left every suite green. +echo "" +echo "--- an agents/<name>.md DIRECTORY does not make a routing target resolve ---" +GHOST_AGENT="$TMPDIR_T/ghost-agent-dir" +write_skill "$GHOST_AGENT/plugins/p/.apm/skills/my-skill" my-skill \ + "Use when doing the thing. Do not use for the other thing — use /ghost-agent instead." +mkdir -p "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md" +set +e +GHOST_AGENT_OUT="$(bash "$HOOK" "$GHOST_AGENT/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)" +GHOST_AGENT_RC=$? +set -e +if [[ $GHOST_AGENT_RC -ne 0 && "$GHOST_AGENT_OUT" == *"routes to 'ghost-agent'"* ]]; then + pass "a DIRECTORY named ghost-agent.md is not a resolvable agent name" +else + fail "a directory named agents/ghost-agent.md resolved a routing target (exit $GHOST_AGENT_RC): ${GHOST_AGENT_OUT:-<empty>}" +fi +# The confirming half, exactly as in 2f: replace the directory with a real file +# and the identical description resolves. Without it the rule could be +# implemented as "agents/ never contributes anything" and still pass above. +rmdir "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md" +: > "$GHOST_AGENT/plugins/p/.apm/agents/ghost-agent.md" +set +e +GHOST_AGENT_OUT="$(bash "$HOOK" "$GHOST_AGENT/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)" +GHOST_AGENT_RC=$? +set -e +if [[ $GHOST_AGENT_RC -eq 0 && -z "$GHOST_AGENT_OUT" ]]; then + pass "the same path as a FILE resolves, so the rule is 'not a file' and not 'never'" +else + fail "a real agents/ghost-agent.md file still did not resolve (exit $GHOST_AGENT_RC): ${GHOST_AGENT_OUT:-<empty>}" +fi + # And the confirming half of the grammar rule: a compound-modifier target is # CONFIRM-ONLY, not ignored. When the name does exist it still counts as a route # — the rule suppresses the ERROR, it does not delete the target. diff --git a/tests/test-skill-size-check.sh b/tests/test-skill-size-check.sh index 2762e8b..ac1bf64 100755 --- a/tests/test-skill-size-check.sh +++ b/tests/test-skill-size-check.sh @@ -702,6 +702,175 @@ expect_gate "a fixture with no authoring root reports DID NOT RUN and exits 0" \ # # If a real dangling target ever reappears, add its probe back here. +# --------------------------------------------------------------------------- +# Usage tier: zero arguments is exit 2, not a clean run +# --------------------------------------------------------------------------- +# The script used to print nothing and exit 0 when handed no paths, which made +# a mis-scoped `files:` pattern indistinguishable from a corpus with no +# findings — the whole ADR-0020 gate family silently disabled while every hook +# reported green. Exit 2 (not 1) is the same split a8cd5e8 made in +# provider-adapter-author's validate-adapter.sh and the one vale-wrap.sh already +# used: {0,1} are verdicts, 2 is "you invoked this wrong". +# +# SAFE FOR THE HOOK. Both manifests declare pass_filenames: true and neither +# sets always_run, and pre-commit skips a filename-passing hook outright when +# its `files:` pattern matches nothing, so pre-commit never invokes this script +# with an empty argument list. That claim is asserted below rather than left in +# prose, so a config edit that turns it false fails here. +echo "" +echo "--- zero arguments is a usage error (exit 2), not a silent clean run ---" +set +e +USAGE_OUT="$("$SCRIPT" 2>&1)" +USAGE_RC=$? +set -e +if [[ $USAGE_RC -eq 2 ]]; then + pass "no arguments exits 2" +else + fail "no arguments exited $USAGE_RC, expected 2 (output: ${USAGE_OUT:-<empty>})" +fi +if [[ "$USAGE_OUT" == *usage* ]]; then + pass "no arguments prints a usage message" +else + fail "no arguments produced no usage message (output: ${USAGE_OUT:-<empty>})" +fi +# The exit code must be DISTINCT from both verdicts, or the split buys nothing. +# $SMALL is the clean fixture built at the top of this file; $MANY_LINES is over +# the line ceiling. +set +e +"$SCRIPT" "$SMALL" > /dev/null 2>&1 +CLEAN_RC=$? +"$SCRIPT" "$MANY_LINES" > /dev/null 2>&1 +FINDING_RC=$? +set -e +if [[ $CLEAN_RC -eq 0 && $FINDING_RC -eq 1 && $USAGE_RC -eq 2 ]]; then + pass "the three exit codes are distinct: clean=0, findings=1, usage=2" +else + fail "exit codes collide — clean=$CLEAN_RC findings=$FINDING_RC usage=$USAGE_RC" +fi +# The hook contract the usage exit depends on. If either manifest ever stops +# passing filenames, or starts always_run, pre-commit could invoke the script +# with no paths and exit 2 would break the hook rather than diagnose a caller. +HOOK_CONTRACT="$(python3 - "$REPO_ROOT" <<'PYHOOK' +import os +import sys + +import yaml + +root = sys.argv[1] +problems = [] + + +def check(label, hook): + if hook is None: + problems.append('%s declares no such hook' % label) + return + if hook.get('pass_filenames') is False: + problems.append('%s sets pass_filenames: false' % label) + if hook.get('always_run'): + problems.append('%s sets always_run: true' % label) + + +with open(os.path.join(root, '.pre-commit-config.yaml'), encoding='utf-8') as fh: + cfg = yaml.safe_load(fh) or {} +found = None +for repo in cfg.get('repos') or []: + for hook in (repo.get('hooks') or []): + if hook.get('id') == 'skill-size-check': + found = hook +check('.pre-commit-config.yaml skill-size-check', found) + +with open(os.path.join(root, '.pre-commit-hooks.yaml'), encoding='utf-8') as fh: + hooks = yaml.safe_load(fh) or [] +found = None +for hook in hooks: + if isinstance(hook, dict) and hook.get('id') == 'kyberforge-skill-size-check': + found = hook +check('.pre-commit-hooks.yaml kyberforge-skill-size-check', found) + +print('; '.join(problems)) +PYHOOK +)" +if [[ -z "$HOOK_CONTRACT" ]]; then + pass "both manifests pass filenames and neither is always_run, so pre-commit never invokes the script with no paths" +else + fail "the usage exit would break the hook: $HOOK_CONTRACT" +fi + +# --------------------------------------------------------------------------- +# An unreadable path is diagnosed ONCE +# --------------------------------------------------------------------------- +# The stat dance lived twice — a bash pre-loop and the Python per-file loop — +# and both printed the same sentence, so one broken file produced two ERROR +# lines with two different "so ... could not be measured" clauses. Duplicated +# output on a blocking gate reads as two problems and sends the author hunting +# for a second one. The check must still FIRE (silence is the failure this +# script forbids itself); it must fire exactly once. +echo "" +echo "--- an unreadable path produces exactly one ERROR line, not two ---" +UNREADABLE_DIR="$TMPDIR/unreadable" +mkdir -p "$UNREADABLE_DIR/a-directory.md" +ln -sf "$TMPDIR/definitely-not-here.md" "$UNREADABLE_DIR/broken-link.md" + +# unreadable_case <label> <path> +unreadable_case() { + local label="$1" path="$2" out status=0 count + set +e + out="$("$SCRIPT" "$path" 2>&1)" + status=$? + set -e + count="$(printf '%s\n' "$out" | grep -cF "ERROR: $path" || true)" + if [[ $status -eq 0 ]]; then + fail "$label: exited 0 — an unmeasurable path passed in silence (output: ${out:-<empty>})" + elif [[ "$count" != "1" ]]; then + fail "$label: $count ERROR lines name the path, expected exactly 1 (output: $out)" + else + pass "$label" + fi +} +unreadable_case "a path that does not exist is reported once" \ + "$TMPDIR/no-such-file.md" +unreadable_case "a DIRECTORY named *.md is reported once" \ + "$UNREADABLE_DIR/a-directory.md" +unreadable_case "a broken symlink is reported once" \ + "$UNREADABLE_DIR/broken-link.md" + +# --------------------------------------------------------------------------- +# Encoding, write side: under LC_ALL=C the report must still print +# --------------------------------------------------------------------------- +# read_text() in the shared ADR-0020 resolver block pins the READS to UTF-8. +# That moved the crash to the WRITE: this script's own message text carries em +# dashes (the boundary SUGGESTION is one), so under LC_ALL=C the streams' ASCII +# default raised UnicodeEncodeError while PRINTING -- after every check had +# already run, losing the whole report at the last step and turning a +# SUGGESTION-only exit 0 into a traceback and an exit 1. +echo "" +echo "--- under LC_ALL=C the SUGGESTION is printed, not lost to a UnicodeEncodeError ---" +LOCALE_SKILL="$TMPDIR/locale-skill" +mkdir -p "$LOCALE_SKILL" +cat > "$LOCALE_SKILL/SKILL.md" <<'LOCALEEOF' +--- +name: locale-skill +description: A valid skill description that is well within the limit. +--- + +## Step 1 + +Do the thing. +LOCALEEOF +set +e +LOCALE_OUT="$(env LC_ALL=C PYTHONUTF8=0 "$SCRIPT" "$LOCALE_SKILL/SKILL.md" 2>&1)" +LOCALE_STATUS=$? +set -e +if [[ $LOCALE_STATUS -ne 0 ]]; then + fail "a SUGGESTION-only subject exited $LOCALE_STATUS under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})" +elif [[ "$LOCALE_OUT" == *UnicodeEncodeError* || "$LOCALE_OUT" == *Traceback* ]]; then + fail "the report died encoding its own message text under LC_ALL=C (output: $LOCALE_OUT)" +elif [[ "$LOCALE_OUT" != *"description has no boundary clause"* ]]; then + fail "the SUGGESTION never reached stdout under LC_ALL=C (output: ${LOCALE_OUT:-<empty>})" +else + pass "the SUGGESTION survives LC_ALL=C, streams pinned to UTF-8" +fi + echo "" echo "Results: $PASS passed, $FAIL failed" [[ $FAIL -eq 0 ]] -- 2.43.0 From 88866b81a39b0c679a07be5e1096f980dda2ba45 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:37:32 +0000 Subject: [PATCH 80/89] fix(kyberforge): announce every provenance skip and scope checks 7-8 to source indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clean provenance bill was an artifact. Checks 7 and 8 assume `Research doc:` names a source index whose H2s are slugs, but 30 of 121 corpus entries point at topic content documents whose H2s are topics. Those 30 produced every new check-7 INFO — all false positives. Check 8 aimed at the same documents, which carry no `Status:` line at all, would have emitted a large false-FAIL flood; the only thing preventing it was an unannounced `rd_status != extracted` skip. So "0 new FAILs" rested on exactly the fail-open class this branch exists to remove, and naively fixing the skip would have turned the branch red. Checks 7/8 now run only when the research doc's basename is `sources.md`, and every other case emits a visible INFO naming the slug. The dangling-path INFO stays ahead of the basename gate, because a path that does not resolve is rot whatever it is named. `parse_status` accepts the bullet form and a trailing note after the backticked value, so a status it cannot read no longer reads as "nothing to check". Corpus: 36 INFOs of which 30 were false, to 56 of which none are. FAIL stays 0, and no Status line flipped to `extracted` under the new parser, so no FAIL was suppressed by luck. Also closed, each a silent pass: a nonexistent directory, a directory with no SKILL.md, and extra arguments now exit 2; a UTF-8 BOM no longer defeats frontmatter parsing; the bare `except Exception: return False` that turned an unreadable file into a clean pass is gone, with all reads pinned to UTF-8; check 3 walks nested `references/` subdirectories; `FILL IN:` at end of line no longer escapes checks 1 and 6; duplicate `## slug` blocks and repeated `Research doc:` lines are announced rather than half-read. The agent-audit copy carried all of the above unfixed and is now ported, minus the four fixes that are genuinely N/A at agent scope — it reads a plugin-root `sources.md` and has no checks 7/8 and no `references/` tree. Its silent exit 0 for a file outside plugin scope is preserved deliberately: that is a verdict about a valid file, not a skip, and `check-scope-walkup-sync.sh` pins it. Every exit-2 gate therefore decides from the argument alone, before the walk-up runs. `validation-scripts.md` said flatly that silence from the validator is a pass, not a skip. That sentence is what made a typo'd path dangerous, and both copies are corrected here. The matching SKILL.md exit-code guidance lands with the audit rubric change, which touches the same files. Tests: skill-audit 45 to 65, agent-audit 24 to 43, every new case proven by mutation. Refs: #111, #118, #121 --- .../references/validation-scripts.md | 10 +- .../scripts/validate-provenance.sh | 294 ++++++++++-- .../tests/validate-provenance.bats | 337 ++++++++++++++ .../references/validation-scripts.md | 9 +- .../scripts/validate-provenance.sh | 382 +++++++++++++-- .../tests/validate-provenance.bats | 436 ++++++++++++++++-- .../references/validation-scripts.md | 10 +- .../scripts/validate-provenance.sh | 294 ++++++++++-- .../references/validation-scripts.md | 9 +- .../scripts/validate-provenance.sh | 382 +++++++++++++-- 10 files changed, 1959 insertions(+), 204 deletions(-) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md b/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md index d6bc4ac..55e949c 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md @@ -34,8 +34,14 @@ first of these: `plugin.json` and no `apm.yml` falls through to project or user scope. `validate-provenance.sh` exits 0 silently when that walk does not land on a package root, and again -when the package has no provenance data. Silence from it is a pass, not a skip you need to -investigate. +when the package has no provenance data. Check the exit code before you believe the silence: + +- **0** — a pass, not a skip you need to investigate. Both silent cases above land here. +- **1** — real findings, on stdout with Why and Fix. +- **2** — the check never ran. A missing, doubled, non-file or wrongly-named argument, an + undecodable `apm.yml`, or an absent `python3`, each with a diagnostic on stderr and no findings + at all. Report the `### Provenance` dimension as unverified and quote the reason. An exit 2 is + never a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. ## Manual fallback diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh index 4b07ae3..1dcc003 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh @@ -16,7 +16,30 @@ 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 @@ -28,6 +51,13 @@ Checks performed: 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 } @@ -36,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 @@ -70,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 @@ -104,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) @@ -227,33 +385,48 @@ def parse_contributing_files(content, slug): 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, None)) + _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): - findings.append(("INFO", desc, fpath, None, None, note)) + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): for entry in findings: @@ -273,38 +446,56 @@ def print_findings(): 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, @@ -340,7 +531,31 @@ 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): + +# 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 @@ -370,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: @@ -383,7 +601,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 5: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; check 5 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( "Research doc field missing", diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats index 186d484..7ae7edf 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate-provenance.bats @@ -570,3 +570,340 @@ EOF 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 ] +} diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md b/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md index 89c7788..a1ddb24 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md @@ -99,9 +99,12 @@ Three ways to read the result wrong: ## Script-specific failures -- **`validate-provenance.sh` printed nothing.** That is a pass, not a skip. It also exits 0 - silently when the skill has no `source_keys` and no `references/sources.md` — nothing to - validate is not a finding. +- **`validate-provenance.sh` printed nothing *and exited 0*.** That is a pass, not a skip — it + exits 0 silently when the skill has no `source_keys` and no `references/sources.md`, and nothing + to validate is not a finding. Check the exit code before you believe the silence: a target that + is not a directory, a directory holding no `SKILL.md`, a missing or extra argument, and an absent + `python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it + as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings. - **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh index 2e8cca9..4ed3629 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate-provenance.sh @@ -13,6 +13,12 @@ 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 @@ -32,6 +38,12 @@ Checks performed: 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 } @@ -40,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' @@ -52,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.""" @@ -219,20 +331,25 @@ def parse_contributing_files(content, slug): 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: @@ -264,8 +381,27 @@ def research_doc_is_none(value): """ 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 @@ -274,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.""" @@ -293,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: @@ -321,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: @@ -338,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() @@ -388,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: @@ -402,16 +586,29 @@ 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: @@ -442,9 +639,32 @@ 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): +# 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 @@ -478,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: @@ -491,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", @@ -537,9 +770,41 @@ for slug in parse_h2_slugs(sources_content): 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: - with open(rd_abs) as f: - rd_content = f.read() + 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( @@ -548,15 +813,15 @@ for slug in parse_h2_slugs(sources_content): 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_path, 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) @@ -564,8 +829,25 @@ for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): # Skip if the research doc explicitly records no contributing files if rd_cf == []: continue - # Skip if status is not `extracted` - if rd_status != "`extracted`": + # Skip if status is not `extracted` — and say so when the skip is what + # kept the slug out of the FAIL below. A status of `referenced` or + # `no content extracted` is a real reason not to demand the slug, but + # it was applied in silence, so an entry that should have been in + # sources.md and a status line nobody had updated produced the same + # output: nothing. Only a MATERIAL skip is announced; when the slug is + # already in sources.md the check passes either way and there is no + # fail-open to disclose. + if rd_status != "extracted": + if rd_slug not in sources_slugs: + shown = f"`{rd_status}`" if rd_status else "absent" + emit_info( + f"Check 8 skipped for research-doc slug '{rd_slug}' — its Status is {shown}, not `extracted`", + f"{rd_rel} (## {rd_slug})", + f"'{rd_rel}' has '## {rd_slug}' with contributing files but Status {shown}, and this skill's " + f"sources.md has no '## {rd_slug}' entry. Check 8 only demands an entry for an `extracted` slug, " + f"so it did not run here. If that status is stale — the content was extracted and the line was never " + f"updated — this skill is missing a source entry; if it is accurate, nothing needs doing." + ) continue # This slug should be in sources.md if rd_slug not in sources_slugs: diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats index aef8d83..c5f7d95 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate-provenance.bats @@ -71,10 +71,10 @@ EOF # holding one skill whose single sources.md slug points at the given # Research doc value. Checks 7 and 8 only run for a skill inside a checkout, # so every upstream case needs this shape; the research doc itself is - # written per test into "$repo/docs/research/my-research.md". + # written per test into "$repo/docs/research/sources.md". make_upstream_skill() { local repo="$1" - local research="${2:-docs/research/my-research.md}" + local research="${2:-docs/research/sources.md}" local skill="$repo/my-skill" mkdir -p "$skill/references" "$repo/docs/research" touch "$repo/.git" @@ -375,7 +375,7 @@ EOF # Create a research doc that does NOT have the slug local research_dir="$TMPDIR/research" mkdir -p "$research_dir" - cat > "$research_dir/my-research.md" <<EOF + cat > "$research_dir/sources.md" <<EOF # Research ## different-slug @@ -410,7 +410,7 @@ EOF mkdir -p "$skill2/references" mkdir -p "$fake_repo/docs/research" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## different-slug @@ -427,7 +427,7 @@ EOF - **URL:** https://example.com/my-source - **Description:** A test source. - **Contributing files:** SKILL.md -- **Research doc:** docs/research/my-research.md +- **Research doc:** docs/research/sources.md - **Status:** \`extracted\` EOF @@ -465,7 +465,7 @@ EOF mkdir -p "$fake_repo/docs/research" # Research doc has my-source (extracted, with a contributing file) AND extra-source (also extracted) - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -487,7 +487,7 @@ EOF - **URL:** https://example.com/my-source - **Description:** A test source. - **Contributing files:** SKILL.md -- **Research doc:** docs/research/my-research.md +- **Research doc:** docs/research/sources.md - **Status:** \`extracted\` EOF @@ -520,7 +520,7 @@ EOF mkdir -p "$skill/references" mkdir -p "$fake_repo/docs/research" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -542,7 +542,7 @@ EOF - **URL:** https://example.com/my-source - **Description:** A test source. - **Contributing files:** SKILL.md -- **Research doc:** docs/research/my-research.md +- **Research doc:** docs/research/sources.md - **Status:** \`extracted\` EOF @@ -566,7 +566,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -592,7 +592,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -618,7 +618,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -644,7 +644,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -670,7 +670,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -727,7 +727,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -751,7 +751,7 @@ EOF local fake_repo="$TMPDIR/fakerepo" make_upstream_skill "$fake_repo" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -864,9 +864,9 @@ EOF @test "check 7 runs: '§' section annotation is stripped before the path is resolved" { local fake_repo="$TMPDIR/fakerepo" - make_upstream_skill "$fake_repo" 'docs/research/my-research.md § "Some Section"' + make_upstream_skill "$fake_repo" 'docs/research/sources.md § "Some Section"' - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## different-slug @@ -877,15 +877,15 @@ EOF run bash "$SCRIPT" "$fake_repo/my-skill" assert_success - assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/sources.md'" refute_output --partial "§" } @test "check 7 runs: '→' section annotation is stripped before the path is resolved" { local fake_repo="$TMPDIR/fakerepo" - make_upstream_skill "$fake_repo" 'docs/research/my-research.md → `## Pushing`' + make_upstream_skill "$fake_repo" 'docs/research/sources.md → `## Pushing`' - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## different-slug @@ -896,15 +896,15 @@ EOF run bash "$SCRIPT" "$fake_repo/my-skill" assert_success - assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/sources.md'" refute_output --partial "→" } @test "check 7 runs: parenthetical annotation is stripped before the path is resolved" { local fake_repo="$TMPDIR/fakerepo" - make_upstream_skill "$fake_repo" "docs/research/my-research.md (whole-document reference)" + make_upstream_skill "$fake_repo" "docs/research/sources.md (whole-document reference)" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## different-slug @@ -915,15 +915,15 @@ EOF run bash "$SCRIPT" "$fake_repo/my-skill" assert_success - assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/my-research.md'" + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/sources.md'" refute_output --partial "whole-document reference" } @test "check 7 runs: a bare path with no annotation survives the strip intact" { local fake_repo="$TMPDIR/fakerepo" - make_upstream_skill "$fake_repo" "docs/research/my-research.md" + make_upstream_skill "$fake_repo" "docs/research/sources.md" - cat > "$fake_repo/docs/research/my-research.md" <<EOF + cat > "$fake_repo/docs/research/sources.md" <<EOF # Research ## my-source @@ -978,7 +978,7 @@ EOF @test "INFO: no repo root above the skill directory names the slug instead of skipping silently" { local skill="$TMPDIR/my-skill" make_skill_with_source_keys "$skill" - make_sources_md "$skill" "my-source" "SKILL.md" "docs/research/my-research.md" + make_sources_md "$skill" "my-source" "SKILL.md" "docs/research/sources.md" run bash "$SCRIPT" "$skill" assert_success @@ -1114,3 +1114,383 @@ EOF assert_output --partial "INFO" assert_output --partial "No source_keys frontmatter" } + +# --------------------------------------------------------------------------- +# Cycle 20 — F: checks 7 and 8 apply only to a research SOURCE INDEX, and +# every skip announces itself +# --------------------------------------------------------------------------- + +@test "F: a topic-doc Research doc is reported as not applicable, not as a missing slug" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" "docs/research/remotes.md" + cat > "$fake_repo/docs/research/remotes.md" <<EOF +# Remotes + +## Core Philosophy + +Prose about remotes. +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Upstream checks not applicable for 'my-source'" + assert_output --partial "is a topic document, not a source index" + refute_output --partial "not found as H2 in research doc" +} + +@test "F: check 7 still runs when the Research doc IS a source index" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + cat > "$fake_repo/docs/research/sources.md" <<EOF +# Sources + +## different-slug + +- **Contributing files:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Slug 'my-source' not found as H2 in research doc 'docs/research/sources.md'" + refute_output --partial "not applicable" +} + +@test "F: check 8 announces the slug it skipped for a non-extracted Status" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + cat > "$fake_repo/docs/research/sources.md" <<EOF +# Sources + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** some-skill/references/extra.md +- **Status:** \`referenced\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Check 8 skipped for research-doc slug 'extra-source'" + assert_output --partial "its Status is \`referenced\`, not \`extracted\`" +} + +@test "F: a Status with a trailing note after the backticked value still reads as extracted" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + cat > "$fake_repo/docs/research/sources.md" <<EOF +# Sources + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** some-skill/references/extra.md +- **Status:** \`extracted\` — partial fetch, section 3 only +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "Research doc slug 'extra-source' missing from skill sources.md" +} + +@test "F: a bullet-form Status still reads as extracted" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + cat > "$fake_repo/docs/research/sources.md" <<EOF +# Sources + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** some-skill/references/extra.md + +**Status:** +- \`extracted\` +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_failure + assert_output --partial "Research doc slug 'extra-source' missing from skill sources.md" +} + +@test "F: a research-doc slug with no Status line at all is announced, not skipped silently" { + local fake_repo="$TMPDIR/fakerepo" + make_upstream_skill "$fake_repo" + cat > "$fake_repo/docs/research/sources.md" <<EOF +# Sources + +## my-source + +- **Contributing files:** some-skill/SKILL.md +- **Status:** \`extracted\` + +## extra-source + +- **Contributing files:** some-skill/references/extra.md +EOF + + run bash "$SCRIPT" "$fake_repo/my-skill" + assert_success + assert_output --partial "Check 8 skipped for research-doc slug 'extra-source'" + assert_output --partial "its Status is absent, not \`extracted\`" +} + +# --------------------------------------------------------------------------- +# Cycle 21 — G1: a bad target is a hard error, not a silent pass +# --------------------------------------------------------------------------- + +@test "G1: a nonexistent directory is a hard error (exit 2), not a silent exit 0" { + run bash "$SCRIPT" "$TMPDIR/does-not-exist" + [ "$status" -eq 2 ] + assert_output --partial "not a directory" +} + +@test "G1: a directory with no SKILL.md is a hard error (exit 2), not a silent exit 0" { + mkdir -p "$TMPDIR/not-a-skill/references" + run bash "$SCRIPT" "$TMPDIR/not-a-skill" + [ "$status" -eq 2 ] + assert_output --partial "not a skill directory" +} + +# --------------------------------------------------------------------------- +# Cycle 22 — G2: a UTF-8 BOM does not disable check 2 +# --------------------------------------------------------------------------- + +@test "G2: a BOM at the head of SKILL.md does not silently disable check 2" { + local skill="$TMPDIR/my-skill" + mkdir -p "$skill/references" + printf '\xef\xbb\xbf' > "$skill/SKILL.md" + cat >> "$skill/SKILL.md" <<EOF +--- +name: my-skill +description: A valid skill description. +metadata: + source_keys: + - my-source +--- + +## Step 1 + +Do the thing. +EOF + cat > "$skill/references/sources.md" <<EOF +# Sources + +## different-source + +- **URL:** https://example.com/different-source +- **Description:** A test source. +- **Contributing files:** references/sources.md +- **Research doc:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "source_keys slug 'my-source' not found in sources.md" +} + +# --------------------------------------------------------------------------- +# Cycle 23 — G3: reads are UTF-8 and an unreadable file is a finding +# --------------------------------------------------------------------------- + +@test "G3: an em dash under LC_ALL=C is read, not turned into a traceback or a silent pass" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + cat > "$skill/references/topic.md" <<EOF +--- +source_keys: + - ghost-source +--- + +Prose — with an em dash. +EOF + + LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0 run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "source_keys slug 'ghost-source' not found in sources.md" + refute_output --partial "Traceback" +} + +@test "G3: a file that is genuinely not UTF-8 is a FAIL, not a clean pass" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + printf -- '---\nsource_keys:\n - my-source\n---\n\nLatin-1 byte: \xe9\n' \ + > "$skill/references/topic.md" + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "not valid UTF-8" + assert_output --partial "references/topic.md" +} + +# --------------------------------------------------------------------------- +# Cycle 24 — G4: check 3 walks references/ recursively +# --------------------------------------------------------------------------- + +@test "G4: a source_keys file in references/<subdir>/ is validated, not skipped" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" + mkdir -p "$skill/references/nested" + cat > "$skill/references/nested/topic.md" <<EOF +--- +source_keys: + - ghost-source +--- + +Nested prose. +EOF + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "source_keys slug 'ghost-source' not found in sources.md" + assert_output --partial "references/nested/topic.md" +} + +# --------------------------------------------------------------------------- +# Cycle 25 — G5: a placeholder at end of line is still a placeholder +# --------------------------------------------------------------------------- + +@test "G5: 'FILL IN:' at end of line is caught by check 1" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** FILL IN: +- **Contributing files:** SKILL.md +- **Research doc:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "Unfilled FILL IN: placeholder" +} + +@test "G5: a Research doc value that is a bare 'FILL IN:' is caught by check 6" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + make_sources_md "$skill" "my-source" "SKILL.md" "FILL IN:" + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "Research doc field is empty or placeholder" +} + +# --------------------------------------------------------------------------- +# Cycle 26 — G6/G7: duplicated entries and duplicated fields are announced +# --------------------------------------------------------------------------- + +@test "G6: a duplicate '## slug' block is announced, not half-checked in silence" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** A test source. +- **Contributing files:** SKILL.md +- **Research doc:** (none) +- **Status:** \`extracted\` + +## my-source + +- **URL:** https://example.com/my-source-again +- **Description:** The same slug a second time. +- **Contributing files:** references/nonexistent.md +- **Research doc:** (none) +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "Duplicate '## my-source' entry in sources.md" + assert_output --partial "only the first block is checked" +} + +@test "G7: a second '- **Research doc:**' line in one entry is announced" { + local skill="$TMPDIR/my-skill" + make_skill_with_source_keys "$skill" + mkdir -p "$skill/references" + cat > "$skill/references/sources.md" <<EOF +# Sources + +## my-source + +- **URL:** https://example.com/my-source +- **Description:** A test source. +- **Contributing files:** SKILL.md +- **Research doc:** (none) +- **Research doc:** docs/research/sources.md +- **Status:** \`extracted\` +EOF + + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "Multiple '- **Research doc:**' lines for 'my-source'" +} + +# --------------------------------------------------------------------------- +# Cycle 27 — G8: usage and environment errors exit 2, never 1 +# --------------------------------------------------------------------------- + +@test "G8: a missing argument exits 2, not 1" { + run bash "$SCRIPT" + [ "$status" -eq 2 ] + assert_output --partial "skill-dir is required" +} + +@test "G8: an extra positional argument is rejected, not silently ignored" { + local skill="$TMPDIR/my-skill" + make_clean_skill "$skill" + run bash "$SCRIPT" "$skill" extra + [ "$status" -eq 2 ] + assert_output --partial "expected exactly one argument" +} + +@test "G8: a missing python3 is reported by name and exits 2, not 127" { + local skill="$TMPDIR/my-skill" + make_clean_skill "$skill" + local stub="$TMPDIR/emptybin" + mkdir -p "$stub" + for cmd in bash cat sed; do + ln -s "$(command -v "$cmd")" "$stub/$cmd" + done + PATH="$stub" run bash "$SCRIPT" "$skill" + [ "$status" -eq 2 ] + assert_output --partial "python3 is required" +} + +@test "G3: an unreadable file is reported even when there is nothing else to validate" { + local skill="$TMPDIR/my-skill" + make_clean_skill "$skill" + mkdir -p "$skill/references" + printf -- '---\nsource_keys:\n - my-source\n---\n\nLatin-1 byte: \xe9\n' \ + > "$skill/references/topic.md" + + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "not valid UTF-8" +} diff --git a/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md b/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md index d6bc4ac..55e949c 100644 --- a/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md +++ b/plugins/kyberforge/skills/agent-audit/references/validation-scripts.md @@ -34,8 +34,14 @@ first of these: `plugin.json` and no `apm.yml` falls through to project or user scope. `validate-provenance.sh` exits 0 silently when that walk does not land on a package root, and again -when the package has no provenance data. Silence from it is a pass, not a skip you need to -investigate. +when the package has no provenance data. Check the exit code before you believe the silence: + +- **0** — a pass, not a skip you need to investigate. Both silent cases above land here. +- **1** — real findings, on stdout with Why and Fix. +- **2** — the check never ran. A missing, doubled, non-file or wrongly-named argument, an + undecodable `apm.yml`, or an absent `python3`, each with a diagnostic on stderr and no findings + at all. Report the `### Provenance` dimension as unverified and quote the reason. An exit 2 is + never a clean pass: empty stdout there means nothing was checked, not that nothing was wrong. ## Manual fallback diff --git a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh index 4b07ae3..1dcc003 100755 --- a/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/agent-audit/scripts/validate-provenance.sh @@ -16,7 +16,30 @@ 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 @@ -28,6 +51,13 @@ Checks performed: 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 } @@ -36,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 @@ -70,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 @@ -104,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) @@ -227,33 +385,48 @@ def parse_contributing_files(content, slug): 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, None)) + _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): - findings.append(("INFO", desc, fpath, None, None, note)) + _record(("INFO", desc, fpath, None, None, note)) def print_findings(): for entry in findings: @@ -273,38 +446,56 @@ def print_findings(): 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, @@ -340,7 +531,31 @@ 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): + +# 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 @@ -370,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: @@ -383,7 +601,17 @@ for slug in parse_h2_slugs(sources_content): ) # Check 5: Research doc field required - rd_value = parse_research_doc(sources_content, slug) + rd_values = parse_research_docs(sources_content, slug) + if len(rd_values) > 1: + emit_info( + f"Multiple '- **Research doc:**' lines for '{slug}' — only the first is used", + f"sources.md (## {slug})", + f"The '## {slug}' entry has {len(rd_values)} Research doc lines; check 5 ran against the first " + f"('{rd_values[0]}') and never looked at the rest. " + f"Keep one Research doc line per entry — if a slug genuinely came from two documents, split it into two slugs, " + f"or name the extra document inside the first value's annotation where it is at least visible." + ) + rd_value = rd_values[0] if rd_values else None if rd_value is None: emit_fail( "Research doc field missing", diff --git a/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md b/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md index 89c7788..a1ddb24 100644 --- a/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md +++ b/plugins/kyberforge/skills/skill-audit/references/validation-scripts.md @@ -99,9 +99,12 @@ Three ways to read the result wrong: ## Script-specific failures -- **`validate-provenance.sh` printed nothing.** That is a pass, not a skip. It also exits 0 - silently when the skill has no `source_keys` and no `references/sources.md` — nothing to - validate is not a finding. +- **`validate-provenance.sh` printed nothing *and exited 0*.** That is a pass, not a skip — it + exits 0 silently when the skill has no `source_keys` and no `references/sources.md`, and nothing + to validate is not a finding. Check the exit code before you believe the silence: a target that + is not a directory, a directory holding no `SKILL.md`, a missing or extra argument, and an absent + `python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it + as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings. - **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted. diff --git a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh index 2e8cca9..4ed3629 100755 --- a/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh +++ b/plugins/kyberforge/skills/skill-audit/scripts/validate-provenance.sh @@ -13,6 +13,12 @@ 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 @@ -32,6 +38,12 @@ Checks performed: 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 } @@ -40,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' @@ -52,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.""" @@ -219,20 +331,25 @@ def parse_contributing_files(content, slug): 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: @@ -264,8 +381,27 @@ def research_doc_is_none(value): """ 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 @@ -274,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.""" @@ -293,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: @@ -321,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: @@ -338,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() @@ -388,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: @@ -402,16 +586,29 @@ 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: @@ -442,9 +639,32 @@ 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): +# 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 @@ -478,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: @@ -491,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", @@ -537,9 +770,41 @@ for slug in parse_h2_slugs(sources_content): 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: - with open(rd_abs) as f: - rd_content = f.read() + 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( @@ -548,15 +813,15 @@ for slug in parse_h2_slugs(sources_content): 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_path, 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) @@ -564,8 +829,25 @@ for rd_abs, (rd_rel, known_slugs) in research_docs_seen.items(): # 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: -- 2.43.0 From 59f27dbd94b46aa5f177d6f79ee93f395511de07 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:38:04 +0000 Subject: [PATCH 81/89] fix(core): close five ways validate-adapter.sh graded an adapter it had not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter check shipped green on files it should have failed, and failed files it should have passed. Each defect is a residual of the fix that closes #115. A fenced, indented or HTML-commented `@AGENTS.md` counted as an import, though Claude Code resolves none of them — the adapter deferred to nothing and the gate said so approvingly. Import matching now runs against a character mask that marks fenced blocks and HTML comments inert, and applies CommonMark's four-space rule. The mask is deliberately not applied to prose pointers, where four-space indentation is ordinary list continuation. The encoding fix reached only BOM-carrying UTF-16/32. BOM-less UTF-16LE/BE and UTF-32LE are valid UTF-8, so they still produced the exact false diagnosis the fix was written to remove: "no reference to AGENTS.md" on a file whose first line is `@AGENTS.md`. A NUL-byte check is the complete signal. BOM stripping is no longer positional, which also drops the mirror-image false FAILs on a doubled or mid-file BOM. `@NOTAGENTS.md`, `@zzzAGENTS.md` and `@docs/does/not/exist/AGENTS.md` all passed: the pattern had no path-segment boundary and the target was never resolved on disk. Both now hold, and a zero-byte or blank target is reported rather than credited. Unresolved candidates print as `Near miss:` lines so the author sees why a line was not counted. `--no-import-syntax` still used substring matching, so `Do NOT read AGENTS.md; it is obsolete.` passed as a pointer. That is #115's own defect surviving in the flag's other mode. A mention must now carry a deference cue and must not be negated. An unreadable file passed `isfile()`, raised, and exited 1 with a traceback and no FAIL line — the one exit code no document covered, while Step 3 says to re-run until it exits 0. It now exits 3 with a diagnostic, and the README states all four codes and what to do about each instead of "exits non-zero on any failure". Tests 18 to 42. Two of the new cases initially survived their own mutation and were strengthened: `@NOTAGENTS.md` was being rejected by the disk check before the token boundary ran, and stripping that boundary leaves the fragment `NOT`, which the negation cue then rejects for an unrelated reason. Refs: #115 --- .../skills/provider-adapter-author/SKILL.md | 6 +- .../provider-adapter-author/scripts/README.md | 23 +- .../scripts/validate-adapter.sh | 384 ++++++++++++++++-- .../tests/validate-adapter.bats | 305 ++++++++++++++ .../skills/provider-adapter-author/SKILL.md | 6 +- .../provider-adapter-author/scripts/README.md | 23 +- .../scripts/validate-adapter.sh | 384 ++++++++++++++++-- 7 files changed, 1041 insertions(+), 90 deletions(-) diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 97f7e16..254196e 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -30,8 +30,8 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. -- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk. +- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -45,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md b/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md index d137d2b..49c4242 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/README.md @@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg | File | Purpose | |------|---------| -| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | +| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | -Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure. +Takes exactly `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored. + +## What counts as a reference to AGENTS.md + +Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line. + +A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass. + +Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer. + +## Exit codes + +The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these. + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Passes every check | Nothing | +| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file | +| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter | +| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter | diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh index 66ba72c..9648442 100755 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -14,6 +14,10 @@ Arguments: adapter-file Path to the provider-specific file to check. agents-md-file Path to the AGENTS.md file it should defer to. + Exactly two positional arguments are accepted. Extra ones are rejected + rather than ignored: a third path silently graded nothing but the first + two, so a typo'd invocation passed against the wrong file. + Options: --no-import-syntax The target provider has no native cross-file import mechanism. Require a plain-text pointer line naming @@ -26,26 +30,69 @@ Options: it's considered no longer "thin". Must be a non-negative integer. Default: 60. --help, -h Show this help and exit 0. + -- End of options; every later argument is positional. + +Both flags also accept the --flag=value form (--max-lines=40). An unknown +option is reported as an unknown option, not as a missing file. + +What counts as a reference: + + In both modes the named path must be a real path segment ending in + AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must + resolve on disk, relative to the adapter file, to a non-empty file. An + adapter deferring to a path that is not there defers to nothing. + + A mention inside a fenced code block, an indented code block, or an HTML + comment is not credited in either mode. Nothing resolves those, so an + adapter whose only "import" is fenced silently defers to nothing. + + With --no-import-syntax the pointer must read as a pointer: the sentence + naming AGENTS.md has to carry a deference cue (see, read, refer to, + documented in, conventions, ...) and must not be a negation ("do not read + AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer. Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) - 2 Usage or input error — a bad or missing argument, a path that is not a - file, or a file that is not UTF-8. Nothing was graded, so there is no - FAIL line and no adapter edit to make: fix the invocation or the file's - encoding and re-run. Kept distinct from 1 because the skill's own - closeout tells the agent to fix every non-zero exit by editing the - provider file, which for a mistyped flag edits the wrong file forever. + 2 Usage or input error — a bad, missing, or extra argument, an unknown + option, a path that is not a file, or a file that is not UTF-8. Nothing + was graded, so there is no FAIL line and no adapter edit to make: fix + the invocation or the file's encoding and re-run. Kept distinct from 1 + because the skill's own closeout tells the agent to fix every non-zero + exit by editing the provider file, which for a mistyped flag edits the + wrong file forever. + 3 A named input file exists but could not be read (permissions, a + directory swapped in mid-run, I/O error). Also not a FAIL: nothing was + graded and the adapter's contents are unknown, so editing it is + guesswork. Fix the file's readability and re-run. EOF } NO_IMPORT_SYNTAX=0 MAX_LINES=60 ARGS=() +END_OF_OPTS=0 + +require_int() { + # $1 = the value to validate + if [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2 + exit 2 + fi +} while [[ $# -gt 0 ]]; do + if [[ $END_OF_OPTS -eq 1 ]]; then + ARGS+=("$1") + shift + continue + fi case "$1" in + --) + END_OF_OPTS=1 + shift + ;; --help|-h) usage exit 0 @@ -54,17 +101,37 @@ while [[ $# -gt 0 ]]; do NO_IMPORT_SYNTAX=1 shift ;; + --no-import-syntax=*) + echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2 + exit 2 + ;; --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 exit 2 fi MAX_LINES="$2" - if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then - echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 + require_int "$MAX_LINES" + shift 2 + ;; + --max-lines=*) + MAX_LINES="${1#--max-lines=}" + if [[ -z "$MAX_LINES" ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 exit 2 fi - shift 2 + require_int "$MAX_LINES" + shift + ;; + -*) + # Reported as an unknown option rather than falling through to the + # positional bucket, where it used to surface as "'--bogus' is not a + # file" — the right exit code attached to a diagnostic that sends the + # reader looking for a path they never typed. + echo "Error: unknown option '$1'." >&2 + echo "" >&2 + usage >&2 + exit 2 ;; *) ARGS+=("$1") @@ -80,6 +147,13 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then exit 2 fi +if [[ ${#ARGS[@]} -gt 2 ]]; then + echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' import sys import os @@ -89,45 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5] no_import_syntax = no_import_syntax == "1" max_lines = int(max_lines) +EXIT_FAIL = 1 +EXIT_USAGE = 2 +EXIT_UNREADABLE = 3 + if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(2) + sys.exit(EXIT_USAGE) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(2) + sys.exit(EXIT_USAGE) def read_text(path): - r"""File contents as text, UTF-8, BOM stripped. + r"""File contents as text, UTF-8, every BOM stripped. - The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is - not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had - its first line — the `@AGENTS.md` import, which is the whole adapter — - silently treated as prose. The check then said "no reference to AGENTS.md" - told the author to add the line already sitting in front of them. Same - class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it - at the reader so no later check has to know about it. + The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM + is not whitespace in Python, so a CLAUDE.md saved by an editor that emits + one had its first line — the `@AGENTS.md` import, which is the whole + adapter — silently treated as prose. The check then said "no reference to + AGENTS.md" and told the author to add the line already sitting in front of + them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh; + strip it at the reader so no later check has to know about it. + + Every U+FEFF goes, not just one at offset 0. Stripping exactly the first + one left the mirror-image false FAIL for a doubled BOM (two concatenated + files, or a tool that re-adds one) and for a BOM mid-file at the head of + the import line. U+FEFF has no meaning as a character in a markdown + instruction file, so removing all of them cannot lose signal. Decoding is strict, not errors="replace". Replacement mangles the file and the checks then grade the mangling: a UTF-16 adapter whose first line is `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", which is a true FAIL for a false reason and points the fix at the wrong - thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and + UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they + decoded clean and produced exactly that false diagnosis anyway. The NUL + byte is the complete signal and is checked first: no plausible markdown + adapter contains one, and every UTF-16/32 encoding of ASCII is full of + them. A file this gate cannot read gets an encoding diagnostic and exit 2, the same policy the ADR-0020 validators' read_text() uses. + + A file that exists but cannot be read at all is neither a pass nor a FAIL — + nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's + closeout into "fix the FAIL by editing the provider file", which for a file + it cannot open is an instruction to edit blind. """ try: - with open(path, encoding="utf-8") as fh: - text = fh.read() + with open(path, "rb") as fh: + raw = fh.read() + except OSError as exc: + print(f"Error: '{path}' exists but could not be read ({exc.strerror}). " + "Nothing was checked — fix whatever is blocking the read " + "(permissions, ownership, the underlying device) and re-run; do " + "not edit the adapter on the strength of this.", file=sys.stderr) + sys.exit(EXIT_UNREADABLE) + if b"\x00" in raw: + print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so " + "it is almost certainly UTF-16 or UTF-32 (with or without a BOM). " + "Re-save it as UTF-8; this check does not guess at other " + "encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + try: + text = raw.decode("utf-8") except UnicodeDecodeError as exc: print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " f"{exc.start}) — re-save it as UTF-8; this check does not guess " "at other encodings.", file=sys.stderr) - sys.exit(2) - return text[1:] if text.startswith("\ufeff") else text + sys.exit(EXIT_USAGE) + return text.replace("\ufeff", "") adapter_content = read_text(adapter_path) agents_md_content = read_text(agents_md_path) +adapter_dir = os.path.dirname(os.path.abspath(adapter_path)) has_fail = False @@ -136,34 +245,229 @@ if not adapter_content.strip(): print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.") print(" Fix: Add at least an import (or text pointer) to AGENTS.md.") print() - sys.exit(1) + sys.exit(EXIT_FAIL) -IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') -lines = adapter_content.splitlines() -import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] -# A prose pointer is any line naming AGENTS.md that is not itself an import -# line — an inert `@AGENTS.md` in a provider that resolves no imports points -# a reader at nothing. -pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln] + +# --- Inert regions ----------------------------------------------------------- +# +# A reference only counts where something would actually resolve it. Fenced +# code blocks, indented code blocks and HTML comments are shown to the reader +# (or hidden from them) as literal text; Claude Code resolves an @import in +# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact +# copy-the-example-into-the-file mistake this gate exists to catch — exited 0 +# with the adapter deferring to nothing. +# +# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the +# mask: four leading spaces is what opens an indented code block in CommonMark, +# so an import has to sit within three. The mask deliberately does not apply +# that rule to prose pointers, where four-space indentation is ordinary list +# continuation rather than code. +FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$') +COMMENT_RE = re.compile(r'<!--.*?(?:-->|\Z)', re.DOTALL) + + +def line_offsets(text): + """[(char offset, line without its terminator)] over `text`.""" + out = [] + off = 0 + for raw in text.splitlines(keepends=True): + out.append((off, raw.rstrip("\r\n"))) + off += len(raw) + return out + + +def build_inert_mask(text, offsets): + """Per-character flags: 1 where a reference would never be resolved.""" + mask = bytearray(len(text)) + fence = None # (fence char, opening run length) + for start, line in offsets: + m = FENCE_RE.match(line) + if fence is None: + if m: + fence = (m.group(2)[0], len(m.group(2))) + for i in range(start, start + len(line)): + mask[i] = 1 + continue + for i in range(start, start + len(line)): + mask[i] = 1 + if (m and m.group(2)[0] == fence[0] + and len(m.group(2)) >= fence[1] + and not m.group(3).strip()): + fence = None + for m in COMMENT_RE.finditer(text): + if m.start() < len(mask) and mask[m.start()]: + continue # a literal "<!--" printed inside a fence opens nothing + for i in range(m.start(), min(m.end(), len(mask))): + mask[i] = 1 + return mask + + +# --- Reference shapes -------------------------------------------------------- +# +# `\S*AGENTS\.md` had no path-separator boundary, so `@NOTAGENTS.md` and +# `@zzzAGENTS.md` counted as imports of AGENTS.md. The matched path must end in +# AGENTS.md as a whole segment. +IMPORT_RE = re.compile(r'^ {0,3}@(?P<path>\S+?)\s*$') +# A mention in prose: an optional relative path, then AGENTS.md, with no +# identifier character glued to the front (so NOTAGENTS.md does not match) and +# nothing glued to the back. +MENTION_RE = re.compile(r'(?<![0-9A-Za-z_.\-/])((?:[\w.\-~]+/)*AGENTS\.md)(?![0-9A-Za-z])') + +# A pointer has to read as a pointer. `"AGENTS.md" in ln` passed +# "Do NOT read AGENTS.md; it is obsolete." and "We deleted AGENTS.md last +# year." — both of which point the reader away from the file. Require a +# deference cue in the naming sentence, and reject a negated one. +DIRECTIVE_RE = re.compile( + r'\b(see|read|refer|refers|referring|consult|consults|follow|follows|' + r'defer|defers|deferring|described|documented|documents|covered|covers|' + r'found|listed|specified|defined|governed|per|use|uses|using|apply|obey|' + r'start|check|live|lives|contains|holds|carries|inherit|inherits|import|' + r'imports|conventions|instructions|guidelines|guidance|rules|standards|' + r'reference|setup)\b', re.I) +NEGATION_RE = re.compile( + r"(\bnot\b|n't\b|\bnever\b|\bno longer\b|\bdeleted\b|\bremoved\b|" + r"\bobsolete\b|\bdeprecated\b|\bignore\b|\bignores\b|\bignoring\b|" + r"\bdisregard\b|\bsuperseded\b|\bgone\b|\bunused\b|\bstale\b)", re.I) +SENTENCE_SPLIT_RE = re.compile(r'(?<=[.;:!?])\s+') + + +def sentence_around(line, index): + """(sentence of `line` containing character `index`, its start offset).""" + bounds = [0] + for m in SENTENCE_SPLIT_RE.finditer(line): + bounds.append(m.end()) + bounds.append(len(line) + 1) + for i in range(len(bounds) - 1): + if bounds[i] <= index < bounds[i + 1]: + return line[bounds[i]:bounds[i + 1]], bounds[i] + return line, 0 + + +def reads_as_pointer(line, match): + """Does the sentence naming AGENTS.md actually point the reader at it? + + The matched path is blanked out before the cues are applied. It is a + filename, not prose, and leaving it in let its own characters vote: the + perfectly ordinary `docs/does/not/exist/AGENTS.md` tripped the negation + cue on the `not` path segment, so a pointer got rejected for the wrong + reason and the near-miss line then reported the wrong diagnosis. + """ + sentence, sentence_start = sentence_around(line, match.start()) + rel_start = match.start() - sentence_start + rel_end = match.end() - sentence_start + probe = sentence[:rel_start] + " AGENTS.md " + sentence[rel_end:] + if NEGATION_RE.search(probe): + return False + return bool(DIRECTIVE_RE.search(probe)) + + +def resolve(raw_path): + """An import/pointer path resolved the way the provider would resolve it.""" + p = os.path.expanduser(raw_path) + if not os.path.isabs(p): + p = os.path.join(adapter_dir, p) + return os.path.normpath(p) + + +def target_problem(raw_path): + """None if `raw_path` names a real, non-empty file; else why not.""" + resolved = resolve(raw_path) + if not os.path.isfile(resolved): + return f"'{raw_path}' resolves to {resolved}, which does not exist" + try: + if os.path.getsize(resolved) == 0: + return f"'{raw_path}' resolves to {resolved}, which is empty" + with open(resolved, "rb") as fh: + if not fh.read().strip(): + return f"'{raw_path}' resolves to {resolved}, which is blank" + except OSError as exc: + return f"'{raw_path}' resolves to {resolved}, which cannot be read ({exc.strerror})" + return None + + +def names_agents_md(path): + return path == "AGENTS.md" or path.endswith("/AGENTS.md") + + +offsets = line_offsets(adapter_content) +lines = [line for _, line in offsets] +mask = build_inert_mask(adapter_content, offsets) + + +def is_inert(abs_index): + return abs_index < len(mask) and bool(mask[abs_index]) + + +# Lines shaped like an @AGENTS.md import, whether or not the target resolves. +# Used to exclude them from the duplication denominator and from the prose +# pointer scan, both of which only care about the shape. +import_shaped_lines = set() +# (line, raw path) for every import whose target actually resolves. +live_imports = [] +# Diagnostics for imports that are the right shape but resolve to nothing. +dead_imports = [] +# Imports that exist only inside a fence or an HTML comment. +inert_imports = [] + +for start, line in offsets: + m = IMPORT_RE.match(line) + if not m or not names_agents_md(m.group("path")): + continue + at_index = start + line.index("@") + if is_inert(at_index): + inert_imports.append(line.strip()) + continue + import_shaped_lines.add(line) + problem = target_problem(m.group("path")) + if problem: + dead_imports.append(problem) + else: + live_imports.append(line) + +live_pointers = [] +dead_pointers = [] +inert_pointers = [] +mention_only = [] + +for start, line in offsets: + if line in import_shaped_lines: + continue + for m in MENTION_RE.finditer(line): + if is_inert(start + m.start()): + inert_pointers.append(line.strip()) + continue + if not reads_as_pointer(line, m): + mention_only.append(sentence_around(line, m.start())[0].strip()) + continue + problem = target_problem(m.group(1)) + if problem: + dead_pointers.append(problem) + else: + live_pointers.append(line) if no_import_syntax: - has_reference = bool(pointer_lines) + has_reference = bool(live_pointers) + near_misses = dead_pointers + [f"{d} (inside a code fence or HTML comment)" for d in inert_pointers] + near_misses += [f"'{s}' names AGENTS.md but does not point at it" for s in mention_only] else: - has_reference = bool(import_lines) + has_reference = bool(live_imports) + near_misses = dead_imports + [f"'{d}' is inside a code fence or HTML comment, where no import is resolved" for d in inert_imports] if not has_reference: has_fail = True print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}") if no_import_syntax: - print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") - print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") + print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text. The pointer has to read as a pointer and name a file that is really there — a bare or negated mention (\"we deleted AGENTS.md\") defers nothing, and neither does a mention buried in a code fence or an HTML comment.") + print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\", outside any fence, naming a path that exists relative to this file.") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") - print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own, indented no more than three spaces, and the path must resolve to a real non-empty file. Naming the file mid-sentence or inside backticks is prose this check will not credit; putting the line inside a ``` fence, an indented code block, or an HTML comment is worse, because nothing resolves it and it looks right.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line at the top level of the file, or pass --no-import-syntax if this provider resolves no imports.") + for miss in near_misses: + print(f" Near miss: {miss}") print() # --- Duplication check --- -non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)] +non_import_lines = [ln for ln in lines if ln not in import_shaped_lines] adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()] agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()} @@ -187,6 +491,6 @@ if non_blank_count > max_lines: print() if has_fail: - sys.exit(1) + sys.exit(EXIT_FAIL) sys.exit(0) PYTHON diff --git a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats index 9d18f1e..2204e35 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats +++ b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats @@ -212,3 +212,308 @@ EOF assert_output --partial "no reference" assert_output --partial "line of its own" } + +# --- Q1: a reference only counts where something would resolve it ------------- + +@test "an @AGENTS.md inside a backtick code fence is not credited as an import" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +# Claude notes + +Put this at the top of the file: + +``` +@AGENTS.md +``` +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @AGENTS.md inside a tilde code fence is not credited as an import" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +~~~ +@AGENTS.md +~~~ +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @AGENTS.md in a four-space indented code block is not credited as an import" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +# Claude notes + + @AGENTS.md +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @AGENTS.md inside a multi-line HTML comment is not credited as an import" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +# Claude notes + +<!-- +@AGENTS.md +--> +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @AGENTS.md indented up to three spaces is still credited" { + ADAPTER="$TMPDIR/CLAUDE.md" + printf ' @AGENTS.md\n' > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q2: encodings that decode as valid UTF-8 but are not UTF-8 --------------- + +@test "a BOM-less UTF-16LE adapter is an encoding error, not a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-16-le'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "a BOM-less UTF-32LE adapter is an encoding error, not a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-32-le'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "a doubled UTF-8 BOM does not hide the @import line" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write(('@AGENTS.md\n').encode('utf-8'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "a BOM in front of a mid-file @import line does not hide it" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write(('# Claude notes\n\n@AGENTS.md\n').encode('utf-8'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q3: a file that exists but cannot be read is not a FAIL ----------------- + +@test "an adapter that exists but cannot be read exits 3 with a diagnostic and no FAIL" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + chmod 000 "$ADAPTER" + # chmod is not enough under a uid that bypasses it (root in CI containers). + # /proc/self/mem is a regular file whose read returns EIO for every uid, so + # it exercises the same branch where chmod cannot. + if cat "$ADAPTER" >/dev/null 2>&1; then + if [ -e /proc/self/mem ]; then + ADAPTER=/proc/self/mem + else + chmod 644 "$TMPDIR/CLAUDE.md" + skip "no way to make a readable-by-stat, unreadable-by-open file here" + fi + fi + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + chmod 644 "$TMPDIR/CLAUDE.md" + assert_failure 3 + assert_output --partial "could not be read" + refute_output --partial "FAIL" +} + +# --- Q4: the reference has to name, and resolve to, a real AGENTS.md --------- + +@test "an @import naming a path that does not exist is not credited" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@docs/does/not/exist/AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "does not exist" +} + +@test "@NOTAGENTS.md and @zzzAGENTS.md are not imports of AGENTS.md" { + # The decoys are real files, so the on-disk resolution check cannot be what + # rejects them. Only the path-segment boundary can — without the fixtures + # this test passes against a substring match and proves nothing. + cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md" + cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md" + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@NOTAGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + + echo "@zzzAGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "an @import resolving to a zero-byte AGENTS.md is not credited" { + SUB="$TMPDIR/empty" + mkdir -p "$SUB" + : > "$SUB/AGENTS.md" + ADAPTER="$SUB/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "empty" +} + +@test "an @import naming a real relative path to AGENTS.md is credited" { + mkdir -p "$TMPDIR/docs" + cp "$AGENTS_MD" "$TMPDIR/docs/AGENTS.md" + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@docs/AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +# --- Q5: --no-import-syntax needs a pointer, not a mention ------------------- + +@test "with --no-import-syntax, a negated mention of AGENTS.md is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +Do NOT read AGENTS.md; it is obsolete. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a past-tense mention of a deleted AGENTS.md is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +We deleted AGENTS.md last year. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer inside a code fence is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +Example of what to write: + +``` +See AGENTS.md at the repo root for shared conventions. +``` +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer inside an HTML comment is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +# Copilot instructions + +<!-- See AGENTS.md at the repo root for shared conventions. --> +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a name merely ending in AGENTS.md is not a pointer to it" { + # Real decoy files, so the on-disk resolution check cannot be what rejects + # these — only the token boundary in the mention pattern can. zzzAGENTS.md + # is the load-bearing case: dropping the boundary from NOTAGENTS.md leaves + # the fragment "NOT" behind, which the negation cue then rejects for an + # unrelated reason, so that case alone would prove nothing. + cp "$AGENTS_MD" "$TMPDIR/zzzAGENTS.md" + cp "$AGENTS_MD" "$TMPDIR/NOTAGENTS.md" + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +See zzzAGENTS.md at the repo root for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + + cat > "$ADAPTER" <<'EOF' +See NOTAGENTS.md at the repo root for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} + +@test "with --no-import-syntax, a pointer naming a path that does not exist is not credited" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +See docs/does/not/exist/AGENTS.md for shared conventions. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "does not exist" +} + +# --- argument handling ------------------------------------------------------- + +@test "a third positional argument is rejected instead of silently ignored" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" "$TMPDIR/also-not-graded.md" + assert_failure 2 + assert_output --partial "exactly 2 positional arguments" + # The usage text this prints mentions the word FAIL, so refute the shape of + # a real finding line rather than the bare word. + refute_output --partial "FAIL Adapter" +} + +@test "an unknown option is reported as an unknown option, not as a missing file" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + run bash "$SCRIPT" --bogus "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "unknown option '--bogus'" + refute_output --partial "'--bogus' is not a file" + refute_output --partial "FAIL Adapter" +} + +@test "--max-lines=N is accepted in the equals form" { + ADAPTER="$TMPDIR/CLAUDE.md" + { + echo "@AGENTS.md" + for i in $(seq 1 10); do echo "Provider-specific line $i unrelated to AGENTS.md content."; done + } > "$ADAPTER" + run bash "$SCRIPT" --max-lines=5 "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "thin" + + run bash "$SCRIPT" --max-lines=40 "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "with --no-import-syntax, a bare mention with no deference cue is not a pointer" { + ADAPTER="$TMPDIR/copilot-instructions.md" + cat > "$ADAPTER" <<'EOF' +# Copilot instructions + +This repo also has an AGENTS.md. + +Prefer inline suggestions over chat for one-line edits. +EOF + run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" +} diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 97f7e16..254196e 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -30,8 +30,8 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. -- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it — nor one inside a code fence, an indented block, or an HTML comment, nor one whose path does not resolve to a real, non-empty file on disk. +- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short sentence pointing at `AGENTS.md` ("See AGENTS.md at the repo root for ..."), keep the provider-specific bucket. A bare or negated mention is not a pointer and will not be credited. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -45,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file> ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exits `2` and `3` are not `FAIL`s and nothing was graded under either, so neither is a reason to touch the adapter: `2` means the invocation or the input is wrong (a bad, missing, or extra argument, an unknown option, or a file that is not UTF-8), and `3` means a named file exists but could not be read. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/scripts/README.md b/plugins/core/skills/provider-adapter-author/scripts/README.md index d137d2b..49c4242 100644 --- a/plugins/core/skills/provider-adapter-author/scripts/README.md +++ b/plugins/core/skills/provider-adapter-author/scripts/README.md @@ -4,6 +4,25 @@ Deterministic self-check this skill shells out to instead of relying on LLM judg | File | Purpose | |------|---------| -| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | +| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a working reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold | -Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure. +Takes exactly `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags (also accepted as `--max-lines=N`). A third positional argument or an unknown option is an error, not something quietly ignored. + +## What counts as a reference to AGENTS.md + +Both modes require the named path to be a real path segment ending in `AGENTS.md` — `AGENTS.md` or `…/AGENTS.md`, not `NOTAGENTS.md` — that resolves on disk, relative to the adapter file, to a non-empty file. An adapter deferring to a path that is not there defers to nothing, so the check has to touch the disk rather than pattern-match the line. + +A reference only counts where something would actually resolve it. A line inside a fenced code block, an indented code block, or an HTML comment is not credited in either mode: Claude Code resolves an import in none of those, so a fenced `@AGENTS.md` is the silent-drop failure this gate exists to catch, not a pass. + +Default mode wants a real import: `@AGENTS.md` alone on its own line, indented no more than three spaces. `--no-import-syntax` wants a prose pointer that reads as one — the sentence naming `AGENTS.md` must carry a deference cue (see, read, refer to, documented in, conventions, …) and must not be negated. `Do NOT read AGENTS.md; it is obsolete.` and `We deleted AGENTS.md last year.` name the file while pointing the reader away from it, and neither is a pointer. + +## Exit codes + +The distinction matters because the skill's closeout tells the agent to fix any non-zero exit by editing the provider file. That is right for exactly one of these. + +| Code | Meaning | What to do | +|------|---------|------------| +| `0` | Passes every check | Nothing | +| `1` | One or more `FAIL` findings printed to stdout — empty adapter, no working reference to AGENTS.md, excessive duplication, or not thin | Edit the provider file | +| `2` | Usage or input error: a bad, missing, or extra argument, an unknown option, a path that is not a file, or a file that is not UTF-8. Nothing was graded, so there is no `FAIL` line | Fix the invocation or the file's encoding — do not edit the adapter | +| `3` | A named input file exists but could not be read (permissions, I/O error). Nothing was graded and the adapter's contents are unknown | Fix the file's readability — do not edit the adapter | diff --git a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh index 66ba72c..9648442 100755 --- a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -14,6 +14,10 @@ Arguments: adapter-file Path to the provider-specific file to check. agents-md-file Path to the AGENTS.md file it should defer to. + Exactly two positional arguments are accepted. Extra ones are rejected + rather than ignored: a third path silently graded nothing but the first + two, so a typo'd invocation passed against the wrong file. + Options: --no-import-syntax The target provider has no native cross-file import mechanism. Require a plain-text pointer line naming @@ -26,26 +30,69 @@ Options: it's considered no longer "thin". Must be a non-negative integer. Default: 60. --help, -h Show this help and exit 0. + -- End of options; every later argument is positional. + +Both flags also accept the --flag=value form (--max-lines=40). An unknown +option is reported as an unknown option, not as a missing file. + +What counts as a reference: + + In both modes the named path must be a real path segment ending in + AGENTS.md ("AGENTS.md" or ".../AGENTS.md" — not NOTAGENTS.md), and it must + resolve on disk, relative to the adapter file, to a non-empty file. An + adapter deferring to a path that is not there defers to nothing. + + A mention inside a fenced code block, an indented code block, or an HTML + comment is not credited in either mode. Nothing resolves those, so an + adapter whose only "import" is fenced silently defers to nothing. + + With --no-import-syntax the pointer must read as a pointer: the sentence + naming AGENTS.md has to carry a deference cue (see, read, refer to, + documented in, conventions, ...) and must not be a negation ("do not read + AGENTS.md", "we deleted AGENTS.md"). A bare mention is not a pointer. Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) - 2 Usage or input error — a bad or missing argument, a path that is not a - file, or a file that is not UTF-8. Nothing was graded, so there is no - FAIL line and no adapter edit to make: fix the invocation or the file's - encoding and re-run. Kept distinct from 1 because the skill's own - closeout tells the agent to fix every non-zero exit by editing the - provider file, which for a mistyped flag edits the wrong file forever. + 2 Usage or input error — a bad, missing, or extra argument, an unknown + option, a path that is not a file, or a file that is not UTF-8. Nothing + was graded, so there is no FAIL line and no adapter edit to make: fix + the invocation or the file's encoding and re-run. Kept distinct from 1 + because the skill's own closeout tells the agent to fix every non-zero + exit by editing the provider file, which for a mistyped flag edits the + wrong file forever. + 3 A named input file exists but could not be read (permissions, a + directory swapped in mid-run, I/O error). Also not a FAIL: nothing was + graded and the adapter's contents are unknown, so editing it is + guesswork. Fix the file's readability and re-run. EOF } NO_IMPORT_SYNTAX=0 MAX_LINES=60 ARGS=() +END_OF_OPTS=0 + +require_int() { + # $1 = the value to validate + if [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Error: --max-lines expects a non-negative integer, got '$1'." >&2 + exit 2 + fi +} while [[ $# -gt 0 ]]; do + if [[ $END_OF_OPTS -eq 1 ]]; then + ARGS+=("$1") + shift + continue + fi case "$1" in + --) + END_OF_OPTS=1 + shift + ;; --help|-h) usage exit 0 @@ -54,17 +101,37 @@ while [[ $# -gt 0 ]]; do NO_IMPORT_SYNTAX=1 shift ;; + --no-import-syntax=*) + echo "Error: --no-import-syntax is a flag and takes no value (got '$1')." >&2 + exit 2 + ;; --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 exit 2 fi MAX_LINES="$2" - if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then - echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 + require_int "$MAX_LINES" + shift 2 + ;; + --max-lines=*) + MAX_LINES="${1#--max-lines=}" + if [[ -z "$MAX_LINES" ]]; then + echo "Error: --max-lines requires a value (a non-negative integer)." >&2 exit 2 fi - shift 2 + require_int "$MAX_LINES" + shift + ;; + -*) + # Reported as an unknown option rather than falling through to the + # positional bucket, where it used to surface as "'--bogus' is not a + # file" — the right exit code attached to a diagnostic that sends the + # reader looking for a path they never typed. + echo "Error: unknown option '$1'." >&2 + echo "" >&2 + usage >&2 + exit 2 ;; *) ARGS+=("$1") @@ -80,6 +147,13 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then exit 2 fi +if [[ ${#ARGS[@]} -gt 2 ]]; then + echo "Error: expected exactly 2 positional arguments (adapter-file and agents-md-file), got ${#ARGS[@]}: ${ARGS[*]}." >&2 + echo "" >&2 + usage >&2 + exit 2 +fi + python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' import sys import os @@ -89,45 +163,80 @@ adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5] no_import_syntax = no_import_syntax == "1" max_lines = int(max_lines) +EXIT_FAIL = 1 +EXIT_USAGE = 2 +EXIT_UNREADABLE = 3 + if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(2) + sys.exit(EXIT_USAGE) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(2) + sys.exit(EXIT_USAGE) def read_text(path): - r"""File contents as text, UTF-8, BOM stripped. + r"""File contents as text, UTF-8, every BOM stripped. - The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is - not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had - its first line — the `@AGENTS.md` import, which is the whole adapter — - silently treated as prose. The check then said "no reference to AGENTS.md" - told the author to add the line already sitting in front of them. Same - class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it - at the reader so no later check has to know about it. + The BOM strip is not cosmetic. IMPORT_RE anchors on `^ {0,3}@`, and a BOM + is not whitespace in Python, so a CLAUDE.md saved by an editor that emits + one had its first line — the `@AGENTS.md` import, which is the whole + adapter — silently treated as prose. The check then said "no reference to + AGENTS.md" and told the author to add the line already sitting in front of + them. Same class of silent BOM miss recorded in scripts/skill-size-check.sh; + strip it at the reader so no later check has to know about it. + + Every U+FEFF goes, not just one at offset 0. Stripping exactly the first + one left the mirror-image false FAIL for a doubled BOM (two concatenated + files, or a tool that re-adds one) and for a BOM mid-file at the head of + the import line. U+FEFF has no meaning as a character in a markdown + instruction file, so removing all of them cannot lose signal. Decoding is strict, not errors="replace". Replacement mangles the file and the checks then grade the mangling: a UTF-16 adapter whose first line is `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", which is a true FAIL for a false reason and points the fix at the wrong - thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + thing. But strict UTF-8 alone does not catch it — BOM-less UTF-16LE/BE and + UTF-32LE are *valid* UTF-8, because NUL is a legal code point, so they + decoded clean and produced exactly that false diagnosis anyway. The NUL + byte is the complete signal and is checked first: no plausible markdown + adapter contains one, and every UTF-16/32 encoding of ASCII is full of + them. A file this gate cannot read gets an encoding diagnostic and exit 2, the same policy the ADR-0020 validators' read_text() uses. + + A file that exists but cannot be read at all is neither a pass nor a FAIL — + nothing was graded — so it exits 3 rather than 1. Exit 1 sends the skill's + closeout into "fix the FAIL by editing the provider file", which for a file + it cannot open is an instruction to edit blind. """ try: - with open(path, encoding="utf-8") as fh: - text = fh.read() + with open(path, "rb") as fh: + raw = fh.read() + except OSError as exc: + print(f"Error: '{path}' exists but could not be read ({exc.strerror}). " + "Nothing was checked — fix whatever is blocking the read " + "(permissions, ownership, the underlying device) and re-run; do " + "not edit the adapter on the strength of this.", file=sys.stderr) + sys.exit(EXIT_UNREADABLE) + if b"\x00" in raw: + print(f"Error: '{path}' is not valid UTF-8 — it contains NUL bytes, so " + "it is almost certainly UTF-16 or UTF-32 (with or without a BOM). " + "Re-save it as UTF-8; this check does not guess at other " + "encodings.", file=sys.stderr) + sys.exit(EXIT_USAGE) + try: + text = raw.decode("utf-8") except UnicodeDecodeError as exc: print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " f"{exc.start}) — re-save it as UTF-8; this check does not guess " "at other encodings.", file=sys.stderr) - sys.exit(2) - return text[1:] if text.startswith("\ufeff") else text + sys.exit(EXIT_USAGE) + return text.replace("\ufeff", "") adapter_content = read_text(adapter_path) agents_md_content = read_text(agents_md_path) +adapter_dir = os.path.dirname(os.path.abspath(adapter_path)) has_fail = False @@ -136,34 +245,229 @@ if not adapter_content.strip(): print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.") print(" Fix: Add at least an import (or text pointer) to AGENTS.md.") print() - sys.exit(1) + sys.exit(EXIT_FAIL) -IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$') -lines = adapter_content.splitlines() -import_lines = [ln for ln in lines if IMPORT_RE.match(ln)] -# A prose pointer is any line naming AGENTS.md that is not itself an import -# line — an inert `@AGENTS.md` in a provider that resolves no imports points -# a reader at nothing. -pointer_lines = [ln for ln in lines if not IMPORT_RE.match(ln) and "AGENTS.md" in ln] + +# --- Inert regions ----------------------------------------------------------- +# +# A reference only counts where something would actually resolve it. Fenced +# code blocks, indented code blocks and HTML comments are shown to the reader +# (or hidden from them) as literal text; Claude Code resolves an @import in +# none of them. Without this, a ```-fenced `@AGENTS.md` — the exact +# copy-the-example-into-the-file mistake this gate exists to catch — exited 0 +# with the adapter deferring to nothing. +# +# Indented code blocks are handled by IMPORT_RE's `^ {0,3}` instead of by the +# mask: four leading spaces is what opens an indented code block in CommonMark, +# so an import has to sit within three. The mask deliberately does not apply +# that rule to prose pointers, where four-space indentation is ordinary list +# continuation rather than code. +FENCE_RE = re.compile(r'^( {0,3})(`{3,}|~{3,})(.*)$') +COMMENT_RE = re.compile(r'<!--.*?(?:-->|\Z)', re.DOTALL) + + +def line_offsets(text): + """[(char offset, line without its terminator)] over `text`.""" + out = [] + off = 0 + for raw in text.splitlines(keepends=True): + out.append((off, raw.rstrip("\r\n"))) + off += len(raw) + return out + + +def build_inert_mask(text, offsets): + """Per-character flags: 1 where a reference would never be resolved.""" + mask = bytearray(len(text)) + fence = None # (fence char, opening run length) + for start, line in offsets: + m = FENCE_RE.match(line) + if fence is None: + if m: + fence = (m.group(2)[0], len(m.group(2))) + for i in range(start, start + len(line)): + mask[i] = 1 + continue + for i in range(start, start + len(line)): + mask[i] = 1 + if (m and m.group(2)[0] == fence[0] + and len(m.group(2)) >= fence[1] + and not m.group(3).strip()): + fence = None + for m in COMMENT_RE.finditer(text): + if m.start() < len(mask) and mask[m.start()]: + continue # a literal "<!--" printed inside a fence opens nothing + for i in range(m.start(), min(m.end(), len(mask))): + mask[i] = 1 + return mask + + +# --- Reference shapes -------------------------------------------------------- +# +# `\S*AGENTS\.md` had no path-separator boundary, so `@NOTAGENTS.md` and +# `@zzzAGENTS.md` counted as imports of AGENTS.md. The matched path must end in +# AGENTS.md as a whole segment. +IMPORT_RE = re.compile(r'^ {0,3}@(?P<path>\S+?)\s*$') +# A mention in prose: an optional relative path, then AGENTS.md, with no +# identifier character glued to the front (so NOTAGENTS.md does not match) and +# nothing glued to the back. +MENTION_RE = re.compile(r'(?<![0-9A-Za-z_.\-/])((?:[\w.\-~]+/)*AGENTS\.md)(?![0-9A-Za-z])') + +# A pointer has to read as a pointer. `"AGENTS.md" in ln` passed +# "Do NOT read AGENTS.md; it is obsolete." and "We deleted AGENTS.md last +# year." — both of which point the reader away from the file. Require a +# deference cue in the naming sentence, and reject a negated one. +DIRECTIVE_RE = re.compile( + r'\b(see|read|refer|refers|referring|consult|consults|follow|follows|' + r'defer|defers|deferring|described|documented|documents|covered|covers|' + r'found|listed|specified|defined|governed|per|use|uses|using|apply|obey|' + r'start|check|live|lives|contains|holds|carries|inherit|inherits|import|' + r'imports|conventions|instructions|guidelines|guidance|rules|standards|' + r'reference|setup)\b', re.I) +NEGATION_RE = re.compile( + r"(\bnot\b|n't\b|\bnever\b|\bno longer\b|\bdeleted\b|\bremoved\b|" + r"\bobsolete\b|\bdeprecated\b|\bignore\b|\bignores\b|\bignoring\b|" + r"\bdisregard\b|\bsuperseded\b|\bgone\b|\bunused\b|\bstale\b)", re.I) +SENTENCE_SPLIT_RE = re.compile(r'(?<=[.;:!?])\s+') + + +def sentence_around(line, index): + """(sentence of `line` containing character `index`, its start offset).""" + bounds = [0] + for m in SENTENCE_SPLIT_RE.finditer(line): + bounds.append(m.end()) + bounds.append(len(line) + 1) + for i in range(len(bounds) - 1): + if bounds[i] <= index < bounds[i + 1]: + return line[bounds[i]:bounds[i + 1]], bounds[i] + return line, 0 + + +def reads_as_pointer(line, match): + """Does the sentence naming AGENTS.md actually point the reader at it? + + The matched path is blanked out before the cues are applied. It is a + filename, not prose, and leaving it in let its own characters vote: the + perfectly ordinary `docs/does/not/exist/AGENTS.md` tripped the negation + cue on the `not` path segment, so a pointer got rejected for the wrong + reason and the near-miss line then reported the wrong diagnosis. + """ + sentence, sentence_start = sentence_around(line, match.start()) + rel_start = match.start() - sentence_start + rel_end = match.end() - sentence_start + probe = sentence[:rel_start] + " AGENTS.md " + sentence[rel_end:] + if NEGATION_RE.search(probe): + return False + return bool(DIRECTIVE_RE.search(probe)) + + +def resolve(raw_path): + """An import/pointer path resolved the way the provider would resolve it.""" + p = os.path.expanduser(raw_path) + if not os.path.isabs(p): + p = os.path.join(adapter_dir, p) + return os.path.normpath(p) + + +def target_problem(raw_path): + """None if `raw_path` names a real, non-empty file; else why not.""" + resolved = resolve(raw_path) + if not os.path.isfile(resolved): + return f"'{raw_path}' resolves to {resolved}, which does not exist" + try: + if os.path.getsize(resolved) == 0: + return f"'{raw_path}' resolves to {resolved}, which is empty" + with open(resolved, "rb") as fh: + if not fh.read().strip(): + return f"'{raw_path}' resolves to {resolved}, which is blank" + except OSError as exc: + return f"'{raw_path}' resolves to {resolved}, which cannot be read ({exc.strerror})" + return None + + +def names_agents_md(path): + return path == "AGENTS.md" or path.endswith("/AGENTS.md") + + +offsets = line_offsets(adapter_content) +lines = [line for _, line in offsets] +mask = build_inert_mask(adapter_content, offsets) + + +def is_inert(abs_index): + return abs_index < len(mask) and bool(mask[abs_index]) + + +# Lines shaped like an @AGENTS.md import, whether or not the target resolves. +# Used to exclude them from the duplication denominator and from the prose +# pointer scan, both of which only care about the shape. +import_shaped_lines = set() +# (line, raw path) for every import whose target actually resolves. +live_imports = [] +# Diagnostics for imports that are the right shape but resolve to nothing. +dead_imports = [] +# Imports that exist only inside a fence or an HTML comment. +inert_imports = [] + +for start, line in offsets: + m = IMPORT_RE.match(line) + if not m or not names_agents_md(m.group("path")): + continue + at_index = start + line.index("@") + if is_inert(at_index): + inert_imports.append(line.strip()) + continue + import_shaped_lines.add(line) + problem = target_problem(m.group("path")) + if problem: + dead_imports.append(problem) + else: + live_imports.append(line) + +live_pointers = [] +dead_pointers = [] +inert_pointers = [] +mention_only = [] + +for start, line in offsets: + if line in import_shaped_lines: + continue + for m in MENTION_RE.finditer(line): + if is_inert(start + m.start()): + inert_pointers.append(line.strip()) + continue + if not reads_as_pointer(line, m): + mention_only.append(sentence_around(line, m.start())[0].strip()) + continue + problem = target_problem(m.group(1)) + if problem: + dead_pointers.append(problem) + else: + live_pointers.append(line) if no_import_syntax: - has_reference = bool(pointer_lines) + has_reference = bool(live_pointers) + near_misses = dead_pointers + [f"{d} (inside a code fence or HTML comment)" for d in inert_pointers] + near_misses += [f"'{s}' names AGENTS.md but does not point at it" for s in mention_only] else: - has_reference = bool(import_lines) + has_reference = bool(live_imports) + near_misses = dead_imports + [f"'{d}' is inside a code fence or HTML comment, where no import is resolved" for d in inert_imports] if not has_reference: has_fail = True print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}") if no_import_syntax: - print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") - print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") + print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text. The pointer has to read as a pointer and name a file that is really there — a bare or negated mention (\"we deleted AGENTS.md\") defers nothing, and neither does a mention buried in a code fence or an HTML comment.") + print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\", outside any fence, naming a path that exists relative to this file.") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") - print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own, indented no more than three spaces, and the path must resolve to a real non-empty file. Naming the file mid-sentence or inside backticks is prose this check will not credit; putting the line inside a ``` fence, an indented code block, or an HTML comment is worse, because nothing resolves it and it looks right.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line at the top level of the file, or pass --no-import-syntax if this provider resolves no imports.") + for miss in near_misses: + print(f" Near miss: {miss}") print() # --- Duplication check --- -non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)] +non_import_lines = [ln for ln in lines if ln not in import_shaped_lines] adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()] agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()} @@ -187,6 +491,6 @@ if non_blank_count > max_lines: print() if has_fail: - sys.exit(1) + sys.exit(EXIT_FAIL) sys.exit(0) PYTHON -- 2.43.0 From fc305ba7d9c5284ea8011e2afe81b63cee285a28 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:38:22 +0000 Subject: [PATCH 82/89] fix(kyberforge): correct the routing-tier contract and make agent-audit rubrics conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five documents told authors that a prose-form dangling routing target blocks. The gate reports it as a SUGGESTION and exits 0. Verified on fixtures: `-> name` and `/name` are blocking ERRORs, the prose form is SUGGESTION-tier unless a second resolving target in the same sentence corroborates it. ADR-0020 and gates.md were right; contract.md, retrofit.md, description-quality.md, finding-criteria.md and agent-author's contract.md were wrong — and they are what an author and an auditor actually read. The whole 39-skill corpus was retrofitted against them. skill-audit was also self-contradictory: it imports validate.sh's SUGGESTIONs into the Structure dimension verbatim while its own rubric grades the same target a FAIL, so one target got reported twice at two tiers. The script owns the grade; the rubric now says so. The YAML-fold trap that broke gitea-labels-milestones (#100) was warned about only in retrofit.md, reachable only from the improve flow when a budget is exceeded. It is now in both contract.md files, which SKILL.md mandates on the create flow too. agent-audit loaded both rubrics unconditionally on every run — 3,323 words for a clean audit against skill-audit's 1,636. dac9cad fixed exactly this in skill-audit and edited agent-audit in the same commit without applying it. Same treatment: the criteria move to a new finding-criteria.md and load per dimension. Clean run now 2,083 words, a 37% cut. Routing: apm-workflow's description shed dependency installation while still owning the flow, and apm-install's boundary did not exclude it, so "install my apm dependencies" matched the CLI-binary skill with no route back. Fixed on both sides. forge regains two of the three phrasings the retrofit deleted. forge Step 1 called grill-with-docs unconditionally — a skill in plugins/bin, which kyberforge does not declare as a dependency. It resolves here only because the walk-up sweeps sibling plugins; a standalone install dead-ends. Step 1 now names the cross-plugin dependency and gives an inline fallback. Declaring it properly in apm.yml remains the better fix. Also: both audit SKILL.md files now grade exit 2 as "did not run, dimension unverified" rather than as findings; skill-audit's README row described content that moved, which its own finding-criteria.md grades a FAIL; and body-discipline.md's `git show <sha>:plugins/...` command is fenced, since an installed plugin cache has no repo and file-structure.md makes a bare repo path a FAIL. Refs: #100, #101, #125 ADR: 0020 --- .../.apm/skills/agent-audit/README.md | 5 +- .../.apm/skills/agent-audit/SKILL.md | 8 +- .../skills/agent-audit/references/README.md | 5 +- .../references/body-and-delegation.md | 24 +---- .../references/description-quality.md | 49 +--------- .../references/finding-criteria.md | 98 +++++++++++++++++++ .../skills/agent-audit/references/sources.md | 10 +- .../agent-author/references/contract.md | 21 +++- .../.apm/skills/apm-install/SKILL.md | 3 +- .../.apm/skills/apm-workflow/SKILL.md | 7 +- plugins/kyberforge/.apm/skills/forge/SKILL.md | 11 ++- .../.apm/skills/skill-audit/README.md | 2 +- .../.apm/skills/skill-audit/SKILL.md | 4 +- .../skill-audit/references/body-discipline.md | 13 ++- .../references/description-quality.md | 7 +- .../references/finding-criteria.md | 10 +- .../skill-author/references/contract.md | 24 ++++- .../skill-author/references/retrofit.md | 25 +++-- .../kyberforge/skills/agent-audit/README.md | 5 +- .../kyberforge/skills/agent-audit/SKILL.md | 8 +- .../skills/agent-audit/references/README.md | 5 +- .../references/body-and-delegation.md | 24 +---- .../references/description-quality.md | 49 +--------- .../references/finding-criteria.md | 98 +++++++++++++++++++ .../skills/agent-audit/references/sources.md | 10 +- .../agent-author/references/contract.md | 21 +++- .../kyberforge/skills/apm-install/SKILL.md | 3 +- .../kyberforge/skills/apm-workflow/SKILL.md | 7 +- plugins/kyberforge/skills/forge/SKILL.md | 11 ++- .../kyberforge/skills/skill-audit/README.md | 2 +- .../kyberforge/skills/skill-audit/SKILL.md | 4 +- .../skill-audit/references/body-discipline.md | 13 ++- .../references/description-quality.md | 7 +- .../references/finding-criteria.md | 10 +- .../skill-author/references/contract.md | 24 ++++- .../skill-author/references/retrofit.md | 25 +++-- 36 files changed, 424 insertions(+), 228 deletions(-) create mode 100644 plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md create mode 100644 plugins/kyberforge/skills/agent-audit/references/finding-criteria.md diff --git a/plugins/kyberforge/.apm/skills/agent-audit/README.md b/plugins/kyberforge/.apm/skills/agent-audit/README.md index d25374f..3face54 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/README.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/README.md @@ -57,8 +57,9 @@ Pass the path to either agent file as the argument. | `assets/vale/styles/Kyberforge/VagueWording.yml` | Flags vague capability wording ("helps with", "utilize", "assists with", "used for") in descriptions | | `assets/vale/styles/KyberforgeCopilot/ProactivePhrase.yml` | Flags CC-specific "Use proactively" phrasing with no effect in Copilot descriptions | | `references/README.md` | Directory documentation for references/ | -| `references/description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL | -| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL and why agents take no body word gate | +| `references/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions | +| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, the delegation FAIL, why agents take no body word gate, and what an agent body is for | | `references/scope-plugin-apm.md` | Scope contract for a single vendor-neutral APM agent file — allowlist, dimension routing, and the dimensions that do not apply | | `references/scope-project-user.md` | Scope contract for a CC / Copilot pair — counterpart derivation, provider field rules, pair consistency | | `references/validation-scripts.md` | Loaded only when a Step 1 script fails or cannot run — scope-detection walk-up, manual fallback checks, known script failures | diff --git a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md index 5086802..19abac3 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md @@ -37,7 +37,7 @@ bash scripts/vale-wrap.sh <agent-file> [<counterpart-file>] If a validation script fails or cannot run — Bash denied, `python3` or `vale` absent, `references/field-inventory.md` missing — read `references/validation-scripts.md`; what these scripts measure is not reproducible by reading. -`validate-provenance.sh` prints nothing on success and runs at plugin/APM scope only, exiting 0 silently elsewhere. Its FAIL findings become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. +`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass, including the silent exit 0 at project or user scope, where plugin-scope provenance does not apply. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a bad argument or a missing dependency, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason; never grade it as a clean pass. `validate.sh` uses the same 2 tier. `vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. At project/user scope pass both files of the pair, not only the one you were handed. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment: @@ -57,14 +57,14 @@ Read the agent file end to end, and at project/user scope its counterpart too. A ## Step 3 — Qualitative audit -Load a dimension's rubric before judging that dimension. +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body, delegation, comment-discipline | `references/body-and-delegation.md` | -Cite file and line number for every finding. +Each rubric is the reasoning behind its criteria, not a second copy of them. Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/README.md b/plugins/kyberforge/.apm/skills/agent-audit/references/README.md index 58bda01..6ae112e 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/README.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/README.md @@ -10,8 +10,9 @@ Additional documentation agents load on demand. | File | Purpose | |------|---------| -| `description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL. | -| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL, why agents take no body word gate, and what an agent body is for. | +| `finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading. | +| `description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions. | +| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, the delegation FAIL, why agents take no body word gate, and what an agent body is for. | | `scope-plugin-apm.md` | Contract for a single vendor-neutral `.apm/agents/<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. | diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md b/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md index ef9e598..325bed6 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md @@ -98,24 +98,8 @@ to a shipped file. At plugin/APM scope the stakes are higher than tidiness: `apm frontmatter verbatim to every target, `<!-- ... -->` is not valid YAML, and `validate.sh` FAILs a frontmatter block that still contains one. -## Auditing guidance +## Where the criteria live -Flag as FAIL if: - -- The body restates a procedure owned by a skill the agent can invoke — Fix: invoke `<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. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md b/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md index d3863af..eac9c27 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md @@ -82,49 +82,8 @@ description: > dispatched and safety-gated. Not conversational git help -> git-workflow. ``` -## Auditing guidance +## Where the criteria live -Flag as FAIL if: - -- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. - `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. Agent - descriptions have no platform-documented ceiling of their own — unlike a skill's 1,024-character - spec limit, the 400-character house ceiling is the only hard limit there is, so do not go looking - for a backstop behind it. -- **Internal mechanics appear in the description.** Any of: - - capability enumeration or a feature list; - - output-format detail ("Produces a compact findings report with Why and Fix per finding"); - - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting - shared agent", "the human-facing entry point", "replaces the old flat invocation"); - - implementation detail ("self-validates via a bundled deterministic script"). - - None of it can change a routing decision and all of it is preloaded. - `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is - judgment. This is the rule that deflates a description, so apply it before reaching for length. -- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted - as user phrasings, usually in the same order. One register, whichever routes better. -- **Descriptive rather than imperative phrasing** (`This agent ...`, `This is the ...`). - `Kyberforge.DescriptionOpener` catches any opener matching `^This`. There is no action-verb rule - here and never was a defensible one: an `Orchestrates ...` or `Audits ...` opener is a catalogue - entry, not a trigger. -- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was - available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is - judgment. -- **A boundary clause naming a target that does not resolve** to a real skill directory or agent - file in the authoring source. `validate.sh` resolves this for agent files at both scopes and - reports each unresolved target itself — take its verdict rather than re-resolving the name by - hand, because a hand-walk over a different universe can contradict it. What is left to you is - semantic and the script cannot reach it: whether a target that *does* resolve is the right - sibling to exclude, and whether a clause naming no target at all ("examine the files manually") - should have named one. -- **`Use proactively` in a Copilot or vendor-neutral description.** - `KyberforgeCopilot.ProactivePhrase` catches it. The phrase steers the Claude Code runtime and - does nothing anywhere else, so in a `.agent.md` it is preloaded text that buys no behaviour. -- **Trigger-list, boundary or indirect-trigger content on a hand-invoked agent** — see Step 0. - -Flag as SUGGESTION if: - -- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL - tier only stops outliers. Report it rather than treating a 399-character description as clean. -- A near-miss exclusion is present but targets a weak near-miss. -- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. +Every FAIL and SUGGESTION criterion for this dimension is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the description dimension in play. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md new file mode 100644 index 0000000..91e431a --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md @@ -0,0 +1,98 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +Flag as FAIL if: + +- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. + `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. Agent + descriptions have no platform-documented ceiling of their own, so 400 is the only hard limit + there is — do not go looking for a backstop behind it. +- **Internal mechanics appear in the description.** Any of: + - capability enumeration or a feature list; + - output-format detail ("Produces a compact findings report with Why and Fix per finding"); + - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting + shared agent", "the human-facing entry point", "replaces the old flat invocation"); + - implementation detail ("self-validates via a bundled deterministic script"). + + None of it can change a routing decision and all of it is preloaded. + `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is + judgment. This is the rule that deflates a description, so apply it before reaching for length. +- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted + as user phrasings, usually in the same order. One register, whichever routes better. +- **Descriptive rather than imperative phrasing** (`This agent ...`, `This is the ...`). + `Kyberforge.DescriptionOpener` catches any opener matching `^This`. +- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was + available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is + judgment. +- **`Use proactively` in a Copilot or vendor-neutral description.** + `KyberforgeCopilot.ProactivePhrase` catches it. The phrase steers the Claude Code runtime and + does nothing anywhere else, so in a `.agent.md` it is preloaded text that buys no behaviour. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked agent** — see Step 0 of + `references/description-quality.md`. + +Flag as SUGGESTION if: + +- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL + tier only stops outliers. Report it rather than treating a 399-character description as clean. +- A near-miss exclusion is present but targets a weak near-miss. +- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. + +**An unresolved boundary target is not graded here.** `validate.sh` resolves boundary targets for +agent files at both scopes and tiers the verdict itself — route notation (`/name`, an arrow form) +is an ERROR, the bare prose form a SUGGESTION unless a second target in the same sentence resolves. +Step 1 has already filed it under `### Structure` at that tier. Take the script's verdict rather +than re-resolving the name by hand, and do not re-grade it under description: a hand-walk over a +different universe can contradict the script, and re-grading puts one target in the report twice. +What is left to judgment is semantic and the script cannot reach it: whether a target that *does* +resolve is the right sibling to exclude, and whether a clause naming no target at all ("examine the +files manually") should have named one. + +## body, delegation and comment-discipline — `references/body-and-delegation.md` + +Flag as FAIL if: + +- The body restates a procedure owned by a skill the agent can invoke — Fix: invoke `<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. There is no number +to cite. The one length signal that applies is the Copilot runtime's 30,000-character body limit, +which `validate.sh` already reports as a SUGGESTION. Length is judged through the delegation FAIL +above instead. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md b/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md index 748c4f8..7fcca6f 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md @@ -14,7 +14,7 @@ source_keys: - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code documentation site indexed by Context7 — plugin manifest schema, subagent definition types, marketplace JSON format, agent markdown file format -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## claude-code-plugins-docs @@ -22,7 +22,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/plugins - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code plugin authoring guide — plugin structure, manifest fields, loading methods, skill namespacing, agent activation, marketplace submission -- **Contributing files:** SKILL.md, references/field-inventory.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md - **Status:** `extracted` ## claude-code-subagents-docs @@ -30,7 +30,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## context7-github-en-copilot @@ -38,7 +38,7 @@ source_keys: - **URL:** context7:/websites/github_en_copilot - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Official GitHub Copilot documentation indexed by Context7; covers CLI plugins, custom agents, SDK, and marketplace -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## github-custom-agents-configuration @@ -46,7 +46,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/reference/custom-agents-configuration - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Reference for cloud and IDE custom agent definition format — frontmatter fields, tool aliases, MCP server config, secrets interpolation, scoping hierarchy -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## github-cli-plugin-reference diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/contract.md b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md index 50f6397..cea9336 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md @@ -61,14 +61,29 @@ word for word. Indirect triggers ("even if the user doesn't say X") take a similar conditional at every scope: add one only where the user's natural phrasing genuinely omits the domain word. -**Boundary targets must resolve.** Both forms are checked — the arrow and the prose form ("do not -use for X, use `y` instead") — so a typo dangles either way. Targets resolve against a universe +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the agent file itself**: the nearest ancestor holding `plugins/*/.apm/{skills,agents}` (or, failing that, the nearest ancestor holding `.git`) contributes every skill and agent under `<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 diff --git a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md index 9b249ea..054d161 100644 --- a/plugins/kyberforge/.apm/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-install/SKILL.md @@ -3,7 +3,8 @@ name: apm-install description: > 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, or auditing apm packages -> `apm-workflow`. + authoring, publishing, auditing, or dependency installation for an apm + package -> `apm-workflow`. metadata: category: apm source_keys: diff --git a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md index 2cc3d55..cd258b4 100644 --- a/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/.apm/skills/apm-workflow/SKILL.md @@ -1,10 +1,9 @@ --- name: apm-workflow description: > - Use when managing an apm package, its apm.yml manifest, or an apm - marketplace — authoring through publishing — even when the user does not say - "apm", e.g. "set up the package manifest". Not the apm binary or an agent - runtime -> `apm-install`. + 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: diff --git a/plugins/kyberforge/.apm/skills/forge/SKILL.md b/plugins/kyberforge/.apm/skills/forge/SKILL.md index e3dabd2..860e9db 100644 --- a/plugins/kyberforge/.apm/skills/forge/SKILL.md +++ b/plugins/kyberforge/.apm/skills/forge/SKILL.md @@ -2,10 +2,11 @@ name: forge description: > Use when the user wants to build or improve something but has not yet named - the artifact type — skill, agent, plugin, or marketplace entry; "a skill for - the gitea plugin, or an agent?". Routes to the matching author skill. Do not - use when the type is already named — invoke `skill-author`, `agent-author` - or `apm-workflow` directly. + 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: @@ -23,6 +24,8 @@ metadata: Call `grill-with-docs` unless a grill session has already run and is available in the context. +`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. + 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. ## Step 2 — Classify and dispatch diff --git a/plugins/kyberforge/.apm/skills/skill-audit/README.md b/plugins/kyberforge/.apm/skills/skill-audit/README.md index dfc5400..277c836 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/README.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/README.md @@ -36,7 +36,7 @@ Provide the path to the skill directory to audit when invoking. | `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/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 — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked (`disable-model-invocation`) contract, the three-part shape, when an indirect trigger is warranted, near-miss exclusions, and a before/after pair | | `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints | | `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed | | `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift | diff --git a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md index 0c86feb..1a66612 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md @@ -33,11 +33,11 @@ bash scripts/validate-provenance.sh <skill-dir> 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: diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index 1157d83..31f28c6 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -135,9 +135,16 @@ Constraints: 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`. Read the snapshot with -`git show 5e23250:plugins/git/.apm/skills/git-commits/SKILL.md`. That body carried -twelve Gotchas, four of which restated content already below them or already in the description: +`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 | |---|---| diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md index 579426c..ef027f8 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md @@ -40,8 +40,11 @@ A model-invoked description carries exactly three things: the agent is deciding whether to act, not reading a catalogue entry. 2. **At most one capability clause.** What it does, in one clause. Never an enumeration. 3. **Boundary clause.** Compressed form: `Not <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`. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md index 73d41c0..05eb85d 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/finding-criteria.md @@ -42,8 +42,6 @@ Flag as FAIL if: - **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 of `references/description-quality.md`. - **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent @@ -56,6 +54,14 @@ Flag as SUGGESTION if: - 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: diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md index faf0479..e6795bf 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/contract.md @@ -47,15 +47,22 @@ explicitly" only where the user's natural phrasing genuinely omits the domain wo for `git-commits`, where the user says "commit". Adding one everywhere is what inflated this corpus, and it was deleted as a blanket rule. -**Boundary targets must resolve.** Both forms are checked — the arrow and the prose form ("do not -use for X, use `y` instead") — so a typo dangles either way. Targets resolve against a universe -built by walking up **from the SKILL.md itself**: the nearest ancestor holding +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the SKILL.md +itself**: the nearest ancestor holding `plugins/*/.apm/{skills,agents}` (or, failing that, the nearest ancestor holding `.git`) contributes every skill and agent under `<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 @@ -68,6 +75,13 @@ checked by nothing and the gate emits a SUGGESTION naming both. Split instead of `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 diff --git a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md index 6cec846..37fa55d 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/.apm/skills/skill-author/references/retrofit.md @@ -122,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 | @@ -139,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. @@ -153,7 +158,9 @@ a skill that was never going to compete, not a second real one. **Never let a hyphenated routing target wrap across lines in a folded `>` scalar.** YAML folding replaces the newline with a space, so `gitea-labels-` at the end of one line and `milestones` at -the start of the next fold into `gitea-labels- milestones`. `validate.sh` then reads the target as -`gitea-labels`, finds no such skill, and reports a dangling boundary target — the live finding on -`gitea-issues` today. Reflow the line so the whole name sits on one of them. The same applies to -any backticked skill or agent name in a description. +the start of the next fold into `gitea-labels- milestones`. The gate then reads the target as +`gitea-labels`, finds no such skill, and reports a dangling boundary target. This is not +hypothetical — it is how `gitea-labels-milestones` broke (issue #100). It is fixed: the corpus +carries no dangling target today, and the repo's test suite pins that set as empty, so a +reintroduction fails the suite rather than joining a backlog. Reflow the line so the whole name +sits on one of them. The same applies to any backticked skill or agent name in a description. diff --git a/plugins/kyberforge/skills/agent-audit/README.md b/plugins/kyberforge/skills/agent-audit/README.md index d25374f..3face54 100644 --- a/plugins/kyberforge/skills/agent-audit/README.md +++ b/plugins/kyberforge/skills/agent-audit/README.md @@ -57,8 +57,9 @@ Pass the path to either agent file as the argument. | `assets/vale/styles/Kyberforge/VagueWording.yml` | Flags vague capability wording ("helps with", "utilize", "assists with", "used for") in descriptions | | `assets/vale/styles/KyberforgeCopilot/ProactivePhrase.yml` | Flags CC-specific "Use proactively" phrasing with no effect in Copilot descriptions | | `references/README.md` | Directory documentation for references/ | -| `references/description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL | -| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL and why agents take no body word gate | +| `references/finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions | +| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, the delegation FAIL, why agents take no body word gate, and what an agent body is for | | `references/scope-plugin-apm.md` | Scope contract for a single vendor-neutral APM agent file — allowlist, dimension routing, and the dimensions that do not apply | | `references/scope-project-user.md` | Scope contract for a CC / Copilot pair — counterpart derivation, provider field rules, pair consistency | | `references/validation-scripts.md` | Loaded only when a Step 1 script fails or cannot run — scope-detection walk-up, manual fallback checks, known script failures | diff --git a/plugins/kyberforge/skills/agent-audit/SKILL.md b/plugins/kyberforge/skills/agent-audit/SKILL.md index 5086802..19abac3 100644 --- a/plugins/kyberforge/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/skills/agent-audit/SKILL.md @@ -37,7 +37,7 @@ bash scripts/vale-wrap.sh <agent-file> [<counterpart-file>] If a validation script fails or cannot run — Bash denied, `python3` or `vale` absent, `references/field-inventory.md` missing — read `references/validation-scripts.md`; what these scripts measure is not reproducible by reading. -`validate-provenance.sh` prints nothing on success and runs at plugin/APM scope only, exiting 0 silently elsewhere. Its FAIL findings become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. +`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass, including the silent exit 0 at project or user scope, where plugin-scope provenance does not apply. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a bad argument or a missing dependency, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason; never grade it as a clean pass. `validate.sh` uses the same 2 tier. `vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. At project/user scope pass both files of the pair, not only the one you were handed. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment: @@ -57,14 +57,14 @@ Read the agent file end to end, and at project/user scope its counterpart too. A ## Step 3 — Qualitative audit -Load a dimension's rubric before judging that dimension. +Read `references/finding-criteria.md` first — every dimension's FAIL and SUGGESTION criteria. Load the rubric below only for a dimension the criteria put in play: one carrying a candidate finding, or one where the criterion alone does not settle the call. -| Dimension | Read | +| Dimension | Rubric | |---|---| | description | `references/description-quality.md` | | body, delegation, comment-discipline | `references/body-and-delegation.md` | -Cite file and line number for every finding. +Each rubric is the reasoning behind its criteria, not a second copy of them. Cite file and line number for every finding. ## Step 4 — Report diff --git a/plugins/kyberforge/skills/agent-audit/references/README.md b/plugins/kyberforge/skills/agent-audit/references/README.md index 58bda01..6ae112e 100644 --- a/plugins/kyberforge/skills/agent-audit/references/README.md +++ b/plugins/kyberforge/skills/agent-audit/references/README.md @@ -10,8 +10,9 @@ Additional documentation agents load on demand. | File | Purpose | |------|---------| -| `description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL. | -| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL, why agents take no body word gate, and what an agent body is for. | +| `finding-criteria.md` | Every dimension's FAIL and SUGGESTION criteria — the one Step 3 file read on every run; it decides which rubrics below are worth loading. | +| `description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked contract, the three-part shape, indirect triggers, and near-miss exclusions. | +| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the core test, the delegation FAIL, why agents take no body word gate, and what an agent body is for. | | `scope-plugin-apm.md` | Contract for a single vendor-neutral `.apm/agents/<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. | diff --git a/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md b/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md index ef9e598..325bed6 100644 --- a/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md +++ b/plugins/kyberforge/skills/agent-audit/references/body-and-delegation.md @@ -98,24 +98,8 @@ to a shipped file. At plugin/APM scope the stakes are higher than tidiness: `apm frontmatter verbatim to every target, `<!-- ... -->` is not valid YAML, and `validate.sh` FAILs a frontmatter block that still contains one. -## Auditing guidance +## Where the criteria live -Flag as FAIL if: - -- The body restates a procedure owned by a skill the agent can invoke — Fix: invoke `<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. diff --git a/plugins/kyberforge/skills/agent-audit/references/description-quality.md b/plugins/kyberforge/skills/agent-audit/references/description-quality.md index d3863af..eac9c27 100644 --- a/plugins/kyberforge/skills/agent-audit/references/description-quality.md +++ b/plugins/kyberforge/skills/agent-audit/references/description-quality.md @@ -82,49 +82,8 @@ description: > dispatched and safety-gated. Not conversational git help -> git-workflow. ``` -## Auditing guidance +## Where the criteria live -Flag as FAIL if: - -- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. - `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. Agent - descriptions have no platform-documented ceiling of their own — unlike a skill's 1,024-character - spec limit, the 400-character house ceiling is the only hard limit there is, so do not go looking - for a backstop behind it. -- **Internal mechanics appear in the description.** Any of: - - capability enumeration or a feature list; - - output-format detail ("Produces a compact findings report with Why and Fix per finding"); - - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting - shared agent", "the human-facing entry point", "replaces the old flat invocation"); - - implementation detail ("self-validates via a bundled deterministic script"). - - None of it can change a routing decision and all of it is preloaded. - `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is - judgment. This is the rule that deflates a description, so apply it before reaching for length. -- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted - as user phrasings, usually in the same order. One register, whichever routes better. -- **Descriptive rather than imperative phrasing** (`This agent ...`, `This is the ...`). - `Kyberforge.DescriptionOpener` catches any opener matching `^This`. There is no action-verb rule - here and never was a defensible one: an `Orchestrates ...` or `Audits ...` opener is a catalogue - entry, not a trigger. -- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was - available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is - judgment. -- **A boundary clause naming a target that does not resolve** to a real skill directory or agent - file in the authoring source. `validate.sh` resolves this for agent files at both scopes and - reports each unresolved target itself — take its verdict rather than re-resolving the name by - hand, because a hand-walk over a different universe can contradict it. What is left to you is - semantic and the script cannot reach it: whether a target that *does* resolve is the right - sibling to exclude, and whether a clause naming no target at all ("examine the files manually") - should have named one. -- **`Use proactively` in a Copilot or vendor-neutral description.** - `KyberforgeCopilot.ProactivePhrase` catches it. The phrase steers the Claude Code runtime and - does nothing anywhere else, so in a `.agent.md` it is preloaded text that buys no behaviour. -- **Trigger-list, boundary or indirect-trigger content on a hand-invoked agent** — see Step 0. - -Flag as SUGGESTION if: - -- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL - tier only stops outliers. Report it rather than treating a 399-character description as clean. -- A near-miss exclusion is present but targets a weak near-miss. -- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. +Every FAIL and SUGGESTION criterion for this dimension is in `references/finding-criteria.md`, +which Step 3 reads on every run. This file is the reasoning behind them, loaded only when that file +puts the description dimension in play. diff --git a/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md new file mode 100644 index 0000000..91e431a --- /dev/null +++ b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md @@ -0,0 +1,98 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Finding Criteria + +Every FAIL and SUGGESTION criterion, for every qualitative dimension, and nothing else. The +reasoning each criterion stands on, its worked examples and its house rules stay in that +dimension's rubric, which Step 3 loads only for a dimension this file puts in play. + +Two rules on using it: + +- A criterion that plainly applies is a finding. Write it up citing file and line. +- A criterion that might apply, or whose call the wording here does not settle, is a reason to load + that dimension's rubric — never a reason to drop the candidate. This file decides which rubrics + to read; it does not settle a close call on its own. + +## description — `references/description-quality.md` + +Flag as FAIL if: + +- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. + `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. Agent + descriptions have no platform-documented ceiling of their own, so 400 is the only hard limit + there is — do not go looking for a backstop behind it. +- **Internal mechanics appear in the description.** Any of: + - capability enumeration or a feature list; + - output-format detail ("Produces a compact findings report with Why and Fix per finding"); + - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting + shared agent", "the human-facing entry point", "replaces the old flat invocation"); + - implementation detail ("self-validates via a bundled deterministic script"). + + None of it can change a routing decision and all of it is preloaded. + `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is + judgment. This is the rule that deflates a description, so apply it before reaching for length. +- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted + as user phrasings, usually in the same order. One register, whichever routes better. +- **Descriptive rather than imperative phrasing** (`This agent ...`, `This is the ...`). + `Kyberforge.DescriptionOpener` catches any opener matching `^This`. +- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was + available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is + judgment. +- **`Use proactively` in a Copilot or vendor-neutral description.** + `KyberforgeCopilot.ProactivePhrase` catches it. The phrase steers the Claude Code runtime and + does nothing anywhere else, so in a `.agent.md` it is preloaded text that buys no behaviour. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked agent** — see Step 0 of + `references/description-quality.md`. + +Flag as SUGGESTION if: + +- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL + tier only stops outliers. Report it rather than treating a 399-character description as clean. +- A near-miss exclusion is present but targets a weak near-miss. +- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. + +**An unresolved boundary target is not graded here.** `validate.sh` resolves boundary targets for +agent files at both scopes and tiers the verdict itself — route notation (`/name`, an arrow form) +is an ERROR, the bare prose form a SUGGESTION unless a second target in the same sentence resolves. +Step 1 has already filed it under `### Structure` at that tier. Take the script's verdict rather +than re-resolving the name by hand, and do not re-grade it under description: a hand-walk over a +different universe can contradict the script, and re-grading puts one target in the report twice. +What is left to judgment is semantic and the script cannot reach it: whether a target that *does* +resolve is the right sibling to exclude, and whether a clause naming no target at all ("examine the +files manually") should have named one. + +## body, delegation and comment-discipline — `references/body-and-delegation.md` + +Flag as FAIL if: + +- The body restates a procedure owned by a skill the agent can invoke — Fix: invoke `<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. There is no number +to cite. The one length signal that applies is the Copilot runtime's 30,000-character body limit, +which `validate.sh` already reports as a SUGGESTION. Length is judged through the delegation FAIL +above instead. diff --git a/plugins/kyberforge/skills/agent-audit/references/sources.md b/plugins/kyberforge/skills/agent-audit/references/sources.md index 748c4f8..7fcca6f 100644 --- a/plugins/kyberforge/skills/agent-audit/references/sources.md +++ b/plugins/kyberforge/skills/agent-audit/references/sources.md @@ -14,7 +14,7 @@ source_keys: - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code documentation site indexed by Context7 — plugin manifest schema, subagent definition types, marketplace JSON format, agent markdown file format -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## claude-code-plugins-docs @@ -22,7 +22,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/plugins - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code plugin authoring guide — plugin structure, manifest fields, loading methods, skill namespacing, agent activation, marketplace submission -- **Contributing files:** SKILL.md, references/field-inventory.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md - **Status:** `extracted` ## claude-code-subagents-docs @@ -30,7 +30,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## context7-github-en-copilot @@ -38,7 +38,7 @@ source_keys: - **URL:** context7:/websites/github_en_copilot - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Official GitHub Copilot documentation indexed by Context7; covers CLI plugins, custom agents, SDK, and marketplace -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## github-custom-agents-configuration @@ -46,7 +46,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/reference/custom-agents-configuration - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Reference for cloud and IDE custom agent definition format — frontmatter fields, tool aliases, MCP server config, secrets interpolation, scoping hierarchy -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md +- **Contributing files:** SKILL.md, references/finding-criteria.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## github-cli-plugin-reference diff --git a/plugins/kyberforge/skills/agent-author/references/contract.md b/plugins/kyberforge/skills/agent-author/references/contract.md index 50f6397..cea9336 100644 --- a/plugins/kyberforge/skills/agent-author/references/contract.md +++ b/plugins/kyberforge/skills/agent-author/references/contract.md @@ -61,14 +61,29 @@ word for word. Indirect triggers ("even if the user doesn't say X") take a similar conditional at every scope: add one only where the user's natural phrasing genuinely omits the domain word. -**Boundary targets must resolve.** Both forms are checked — the arrow and the prose form ("do not -use for X, use `y` instead") — so a typo dangles either way. Targets resolve against a universe +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the agent file itself**: the nearest ancestor holding `plugins/*/.apm/{skills,agents}` (or, failing that, the nearest ancestor holding `.git`) contributes every skill and agent under `<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 diff --git a/plugins/kyberforge/skills/apm-install/SKILL.md b/plugins/kyberforge/skills/apm-install/SKILL.md index 9b249ea..054d161 100644 --- a/plugins/kyberforge/skills/apm-install/SKILL.md +++ b/plugins/kyberforge/skills/apm-install/SKILL.md @@ -3,7 +3,8 @@ name: apm-install description: > 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, or auditing apm packages -> `apm-workflow`. + authoring, publishing, auditing, or dependency installation for an apm + package -> `apm-workflow`. metadata: category: apm source_keys: diff --git a/plugins/kyberforge/skills/apm-workflow/SKILL.md b/plugins/kyberforge/skills/apm-workflow/SKILL.md index 2cc3d55..cd258b4 100644 --- a/plugins/kyberforge/skills/apm-workflow/SKILL.md +++ b/plugins/kyberforge/skills/apm-workflow/SKILL.md @@ -1,10 +1,9 @@ --- name: apm-workflow description: > - Use when managing an apm package, its apm.yml manifest, or an apm - marketplace — authoring through publishing — even when the user does not say - "apm", e.g. "set up the package manifest". Not the apm binary or an agent - runtime -> `apm-install`. + 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: diff --git a/plugins/kyberforge/skills/forge/SKILL.md b/plugins/kyberforge/skills/forge/SKILL.md index e3dabd2..860e9db 100644 --- a/plugins/kyberforge/skills/forge/SKILL.md +++ b/plugins/kyberforge/skills/forge/SKILL.md @@ -2,10 +2,11 @@ name: forge description: > Use when the user wants to build or improve something but has not yet named - the artifact type — skill, agent, plugin, or marketplace entry; "a skill for - the gitea plugin, or an agent?". Routes to the matching author skill. Do not - use when the type is already named — invoke `skill-author`, `agent-author` - or `apm-workflow` directly. + 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: @@ -23,6 +24,8 @@ metadata: Call `grill-with-docs` unless a grill session has already run and is available in the context. +`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. + 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. ## Step 2 — Classify and dispatch diff --git a/plugins/kyberforge/skills/skill-audit/README.md b/plugins/kyberforge/skills/skill-audit/README.md index dfc5400..277c836 100644 --- a/plugins/kyberforge/skills/skill-audit/README.md +++ b/plugins/kyberforge/skills/skill-audit/README.md @@ -36,7 +36,7 @@ Provide the path to the skill directory to audit when invoking. | `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/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 — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL | +| `references/description-quality.md` | Rubric for the description dimension — why the description is the expensive part, the hand-invoked (`disable-model-invocation`) contract, the three-part shape, when an indirect trigger is warranted, near-miss exclusions, and a before/after pair | | `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints | | `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed | | `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift | diff --git a/plugins/kyberforge/skills/skill-audit/SKILL.md b/plugins/kyberforge/skills/skill-audit/SKILL.md index 0c86feb..1a66612 100644 --- a/plugins/kyberforge/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/skills/skill-audit/SKILL.md @@ -33,11 +33,11 @@ bash scripts/validate-provenance.sh <skill-dir> 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: diff --git a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md index 1157d83..31f28c6 100644 --- a/plugins/kyberforge/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/skills/skill-audit/references/body-discipline.md @@ -135,9 +135,16 @@ Constraints: 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`. Read the snapshot with -`git show 5e23250:plugins/git/.apm/skills/git-commits/SKILL.md`. That body carried -twelve Gotchas, four of which restated content already below them or already in the description: +`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 | |---|---| diff --git a/plugins/kyberforge/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/skills/skill-audit/references/description-quality.md index 579426c..ef027f8 100644 --- a/plugins/kyberforge/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/skills/skill-audit/references/description-quality.md @@ -40,8 +40,11 @@ A model-invoked description carries exactly three things: the agent is deciding whether to act, not reading a catalogue entry. 2. **At most one capability clause.** What it does, in one clause. Never an enumeration. 3. **Boundary clause.** Compressed form: `Not <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`. diff --git a/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md index 73d41c0..05eb85d 100644 --- a/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md +++ b/plugins/kyberforge/skills/skill-audit/references/finding-criteria.md @@ -42,8 +42,6 @@ Flag as FAIL if: - **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 of `references/description-quality.md`. - **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent @@ -56,6 +54,14 @@ Flag as SUGGESTION if: - 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: diff --git a/plugins/kyberforge/skills/skill-author/references/contract.md b/plugins/kyberforge/skills/skill-author/references/contract.md index faf0479..e6795bf 100644 --- a/plugins/kyberforge/skills/skill-author/references/contract.md +++ b/plugins/kyberforge/skills/skill-author/references/contract.md @@ -47,15 +47,22 @@ explicitly" only where the user's natural phrasing genuinely omits the domain wo for `git-commits`, where the user says "commit". Adding one everywhere is what inflated this corpus, and it was deleted as a blanket rule. -**Boundary targets must resolve.** Both forms are checked — the arrow and the prose form ("do not -use for X, use `y` instead") — so a typo dangles either way. Targets resolve against a universe -built by walking up **from the SKILL.md itself**: the nearest ancestor holding +**Boundary targets must resolve, and the notation decides how hard the gate bites.** Route +notation — `/name`, or any arrow form (`-> name`, `` -> `name` ``) — is checked +unconditionally: an unresolved target there is a blocking ERROR. The prose form ("do not use +for X, use `y` instead") is only a SUGGESTION by default, because a bare hyphenated word in a +boundary clause is as likely to be a tool, a file format or an English compound as a route. It +is promoted to a blocking ERROR only when a second target in the same sentence *does* resolve, +which corroborates that the name was meant as a route. So a typo does **not** dangle equally +either way — write the arrow when you want the target checked. Targets resolve against a universe built by walking up **from the SKILL.md +itself**: the nearest ancestor holding `plugins/*/.apm/{skills,agents}` (or, failing that, the nearest ancestor holding `.git`) contributes every skill and agent under `<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 @@ -68,6 +75,13 @@ checked by nothing and the gate emits a SUGGESTION naming both. Split instead of `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 diff --git a/plugins/kyberforge/skills/skill-author/references/retrofit.md b/plugins/kyberforge/skills/skill-author/references/retrofit.md index 6cec846..37fa55d 100644 --- a/plugins/kyberforge/skills/skill-author/references/retrofit.md +++ b/plugins/kyberforge/skills/skill-author/references/retrofit.md @@ -122,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 | @@ -139,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. @@ -153,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. -- 2.43.0 From 20e5627fd7efdab0f9077965f1b2b31e963a7d03 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:38:38 +0000 Subject: [PATCH 83/89] fix(git): correct three command claims the plugin got wrong, and give rebase an owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three statements were false against the git in use (2.39.5), each verified by running it. `git rebase --autosquash HEAD~N` without `-i` is a silent no-op: git prints "Successfully rebased", the `fixup!` commit survives with the same SHA, and rewrite-history.md presented that as the preferred flow with `-i` as an optional review step. The agent reports the squash as done and the `fixup!` subject then trips this repo's own commit-msg gate. `-i` is now the command, not the alternative. "Fetch never modifies a local branch, so it is always safe to run" — new text on this branch — is false: `git fetch origin main:probe` fast-forwarded the local branch, and a `+` prefix force-updates it, losing commits. The claim is scoped to the no-refspec form. `git worktree add --orphan` does not exist before Git 2.42; on 2.39.5 it is `error: unknown option 'orphan'`, exit 129. The same file gives a version floor for `--recurse-submodules` three sections earlier. Floor added, with a fallback that was tested before being documented. git-workflow claimed "every request resolves to exactly one of these six" while rebase, reset and stash were owned by no skill — `git reset` appeared nowhere in the plugin, old tree or new — so "undo my last commit" routed nowhere. The claim is gone, the router gains rows for all three, and the procedures now exist: plain rebase with `--onto` and conflict handling, a reset mode table gated on `--hard`, and stash save/pop/list/drop. `--hard` gets an always-loaded Gotcha, matching the register the force-push refusal already sets. Cherry-pick had three claimants pointing at git-history while git-commits owned the flow, and the two copies were not equivalent — git-history's lacked the destination check, the rtk prefix and `--abort`. Resolved to git-commits per #112; the weaker duplicate is replaced by a hand-off. git-commits' metadata.version was deleted rather than bumped in 14af50b, leaving two house-contract documents citing a worked v0.1.2 to v0.1.3 transition against a file declaring no version. Restored to 0.1.3. Also: the divergent-pull explanation stated a `--ff-only` default that does not exist (it is a hard error); `--remote` "requires" a configured branch where it uses one; `git push origin --delete` moved to git-remotes, which owns the remote-side gates; seven retired `git:<name>` source slugs; git-orchestrate quoted a git-workflow sentence that no longer exists; git-submodules regains the indirect trigger that made "add a dependency repo" routable; and commit atomicity is a common gate rather than reachable only from the create flow. Refs: #112, #113 --- .../git/.apm/agents/git-orchestrate.agent.md | 2 +- .../git/.apm/skills/git-branches/README.md | 7 ++-- plugins/git/.apm/skills/git-branches/SKILL.md | 4 +- .../references/branch-operations.md | 24 ++++++++++- .../skills/git-branches/references/merging.md | 4 +- plugins/git/.apm/skills/git-commits/SKILL.md | 13 +++--- .../git-commits/references/rewrite-history.md | 41 ++++++++++++++++++- plugins/git/.apm/skills/git-history/README.md | 2 +- plugins/git/.apm/skills/git-history/SKILL.md | 2 +- .../skills/git-remotes/references/fetch.md | 4 +- .../skills/git-remotes/references/pull.md | 8 ++-- .../git/.apm/skills/git-submodules/SKILL.md | 3 +- .../references/setup-and-update.md | 4 +- .../git-submodules/references/sources.md | 14 +++---- plugins/git/.apm/skills/git-workflow/SKILL.md | 7 +++- .../git-worktrees/references/worktrees.md | 19 ++++++++- plugins/git/agents/git-orchestrate.agent.md | 2 +- plugins/git/skills/git-branches/README.md | 7 ++-- plugins/git/skills/git-branches/SKILL.md | 4 +- .../references/branch-operations.md | 24 ++++++++++- .../skills/git-branches/references/merging.md | 4 +- plugins/git/skills/git-commits/SKILL.md | 13 +++--- .../git-commits/references/rewrite-history.md | 41 ++++++++++++++++++- plugins/git/skills/git-history/README.md | 2 +- plugins/git/skills/git-history/SKILL.md | 2 +- .../skills/git-remotes/references/fetch.md | 4 +- .../git/skills/git-remotes/references/pull.md | 8 ++-- plugins/git/skills/git-submodules/SKILL.md | 3 +- .../references/setup-and-update.md | 4 +- .../git-submodules/references/sources.md | 14 +++---- plugins/git/skills/git-workflow/SKILL.md | 7 +++- .../git-worktrees/references/worktrees.md | 19 ++++++++- 32 files changed, 246 insertions(+), 70 deletions(-) diff --git a/plugins/git/.apm/agents/git-orchestrate.agent.md b/plugins/git/.apm/agents/git-orchestrate.agent.md index a847abb..f4b68d5 100644 --- a/plugins/git/.apm/agents/git-orchestrate.agent.md +++ b/plugins/git/.apm/agents/git-orchestrate.agent.md @@ -16,7 +16,7 @@ You are the orchestrator for the git plugin—a composable workflow dispatcher d You act on the caller's real branch and session context (you explicitly carry forward `current_branch`), not a disposable copy — you do not run in an isolated worktree. -**Scope:** this orchestrator routes git-object operations only (commits, branches, worktrees, remotes, submodules, history). `pc-author` and `pc-run` (pre-commit config authoring and hook execution) are intentionally not routed here — they operate on `.pre-commit-config.yaml` and hook installation, not git objects. `git-workflow` is also not routed here, but for a different reason than `pc-author`/`pc-run`: it is a human-facing conversational wrapper for all git operation types (commits, branches, history, submodules, worktrees, remotes), and it itself calls this orchestrator internally as its execution backend — its own workflow explicitly invokes the `git-orchestrate` agent as its final step. It is not a peer to invoke instead of this dispatcher, and it explicitly refuses agent callers ("Do not use when the caller is an agent"). Agent callers route git-object operations here directly; direct human users to `git-workflow` when they want guided, conversational git help — it will call back into this orchestrator itself. Invoke `pc-author`/`pc-run` directly rather than through this dispatcher; do not invoke `git-workflow` as an agent caller under any circumstance. +**Scope:** this orchestrator routes git-object operations only (commits, branches, worktrees, remotes, submodules, history). `pc-author` and `pc-run` (pre-commit config authoring and hook execution) are intentionally not routed here — they operate on `.pre-commit-config.yaml` and hook installation, not git objects. `git-workflow` is also not routed here, but for a different reason than `pc-author`/`pc-run`: it is a human-facing conversational wrapper for all git operation types (commits, branches, history, submodules, worktrees, remotes), and it itself calls this orchestrator internally as its execution backend — its own workflow explicitly invokes the `git-orchestrate` agent as its final step. It is not a peer to invoke instead of this dispatcher, and its own boundary clause sends agent callers here ("Not an agent caller -> `git-orchestrate`"). Agent callers route git-object operations here directly; direct human users to `git-workflow` when they want guided, conversational git help — it will call back into this orchestrator itself. Invoke `pc-author`/`pc-run` directly rather than through this dispatcher; do not invoke `git-workflow` as an agent caller under any circumstance. ## Hard rules diff --git a/plugins/git/.apm/skills/git-branches/README.md b/plugins/git/.apm/skills/git-branches/README.md index b3626dd..5c4f104 100644 --- a/plugins/git/.apm/skills/git-branches/README.md +++ b/plugins/git/.apm/skills/git-branches/README.md @@ -20,7 +20,7 @@ Describe your branch task: create a feature/hotfix/release branch, switch, delet |------|---------| | `SKILL.md` | Skill instructions for agents | | `references/branch-patterns.md` | Loaded when a branch's base, name prefix, or merge rule depends on GitHub Flow vs. Gitflow | -| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list action, or resolving `get-intent` | +| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list/stash action, or resolving `get-intent` | | `references/merging.md` | Loaded when merging one branch into another or resolving merge conflicts | | `references/comparing-branches.md` | Loaded when comparing two branches or finding where they diverged | | `references/orchestrator-contract.md` | Loaded when `git-orchestrate` or another calling agent supplies a structured request rather than prose | @@ -29,5 +29,6 @@ Describe your branch task: create a feature/hotfix/release branch, switch, delet ## Composition `git-orchestrate` calls this skill for the branch step of a multi-step workflow and parses its -structured result. Cherry-pick and revert are `git-history`'s; commit authoring and rebase are -`git-commits`'; branch operations against a Gitea-hosted remote are `gitea-branches`'. +structured result. Revert is `git-history`'s; commit authoring, rebase, reset and cherry-pick are +`git-commits`'; deleting a remote branch is `git-remotes`'; branch operations against a +Gitea-hosted remote are `gitea-branches`'. diff --git a/plugins/git/.apm/skills/git-branches/SKILL.md b/plugins/git/.apm/skills/git-branches/SKILL.md index ca98aaf..44d79c9 100644 --- a/plugins/git/.apm/skills/git-branches/SKILL.md +++ b/plugins/git/.apm/skills/git-branches/SKILL.md @@ -33,7 +33,7 @@ The two patterns are not mixable, and the wrong merge rule silently damages hist | Action | Reference | |---|---| -| create, switch, delete, rename, track, list, get-intent | `references/branch-operations.md` | +| create, switch, delete, rename, track, list, get-intent, stash | `references/branch-operations.md` | | merge a branch, resolve merge conflicts | `references/merging.md` | | compare two branches, find their divergence | `references/comparing-branches.md` | @@ -51,7 +51,7 @@ These gates are passable. The `main`/`master` refusal in Gotchas is not. ## Step 4 — Set tracking -When pushing a branch for the first time, always `git push -u origin <branch>`. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. +A new branch's first push must be `git push -u origin <branch>`. The push itself is `git-remotes`' — every remote-side gate lives there, which is why Step 2's remote-delete row hands off the same way — but the upstream requirement originates here, so carry it in the handoff. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. ## Step 5 — Return a structured result diff --git a/plugins/git/.apm/skills/git-branches/references/branch-operations.md b/plugins/git/.apm/skills/git-branches/references/branch-operations.md index 8c428cc..b9e29f4 100644 --- a/plugins/git/.apm/skills/git-branches/references/branch-operations.md +++ b/plugins/git/.apm/skills/git-branches/references/branch-operations.md @@ -15,7 +15,9 @@ hatch. - **delete (local)** — `git branch -d <branch>` refuses when the branch holds unmerged commits, which is why it is the default. `git branch -D <branch>` forces the deletion and discards that work — only after the destructive-operation gates pass and `confirm: true` is set. -- **delete (remote)** — `git push origin --delete <branch>`. +- **delete (remote)** — not this skill's. Deleting a remote branch is a push, and every remote-side + gate lives in `git-remotes`; hand it there rather than running the push from here. Its + `references/push.md` carries the command and the refspec form. - **rename** — `git branch -m <old> <new>`. - **list** — `git branch` (local), `-a` (local plus remote-tracking), `-r` (remote-tracking only), `--merged` / `--no-merged` (filter by merge status into the current branch). @@ -32,3 +34,23 @@ On `get-intent`, either parse the intent back out of the branch-name convention (`feature/<intent-slug>`) or return `{ "intent": null }` when the caller never persisted the create-time value. Never fabricate an intent: a downstream commit message built on a guessed intent is worse than one built on none. + +## Stashing work in progress + +A switch aborts rather than clobbering conflicting local changes (see Gotchas). Stash is the way +past it: it shelves the working tree and index so the branch pointer can move. + +- **save** — `git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git + 2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes + reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message. +- **restore** — `git stash pop` applies the newest entry and deletes it. `git stash apply stash@{n}` + applies without deleting, for replaying one shelf onto more than one branch. +- **list** — `git stash list`; `git stash show -p stash@{n}` prints that entry's diff. +- **drop** — `git stash drop stash@{n}` deletes one entry. `git stash clear` deletes all of them + and nothing recovers them — confirm before running it. +- **branch from a stash** — `git stash branch <branch> stash@{n}` creates a branch at the commit the + stash was taken from and pops it there. Use it when the stash no longer applies to the current tip. + +**A conflicting `pop` keeps the entry.** Verified on 2.39.5: it exits 1, writes conflict markers, +prints "The stash entry is kept in case you need it again", and `git stash list` still shows it. +Resolve, `git add`, then `git stash drop` the entry by hand — otherwise it silently accumulates. diff --git a/plugins/git/.apm/skills/git-branches/references/merging.md b/plugins/git/.apm/skills/git-branches/references/merging.md index d1721e3..0302138 100644 --- a/plugins/git/.apm/skills/git-branches/references/merging.md +++ b/plugins/git/.apm/skills/git-branches/references/merging.md @@ -6,8 +6,8 @@ source_keys: # Merging one branch into another -Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase belongs to -`git-commits`; cherry-pick and revert to `git-history`. +Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase and cherry-pick +belong to `git-commits`; revert to `git-history`. - **Fast-forward** — `git merge <branch>` advances the pointer with no merge commit when the target has not diverged. diff --git a/plugins/git/.apm/skills/git-commits/SKILL.md b/plugins/git/.apm/skills/git-commits/SKILL.md index 3b9683c..59ca602 100644 --- a/plugins/git/.apm/skills/git-commits/SKILL.md +++ b/plugins/git/.apm/skills/git-commits/SKILL.md @@ -8,6 +8,7 @@ description: > Not branch lifecycle -> `git-branches`. metadata: + version: "0.1.3" category: git source_keys: - conventional-commits-spec @@ -22,6 +23,7 @@ allowed-tools: Bash - **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. - **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it. +- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first. - **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning. ## Dispatch @@ -31,18 +33,19 @@ Read exactly one flow file. Each is self-contained. | Condition | Flow | Read | |---|---|---| | Composing a new commit from staged changes | create | `references/create-commit.md` | -| Amending, squashing, or folding a fixup into an earlier commit | rewrite | `references/rewrite-history.md` | +| Amending, squashing, folding a fixup, rebasing onto a new base, or resetting HEAD | rewrite | `references/rewrite-history.md` | | Replaying an existing commit onto the current branch | cherry-pick | `references/cherry-pick.md` | ## Gates on every flow 1. **Confirmation.** No history rewrite executes without explicit approval from the user or the calling agent. Cherry-pick needs the destination branch confirmed first. -2. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, +2. **Atomicity.** The result must be one logical, independently reviewable and reversible change that leaves the repository buildable and testable. This binds an amend or a squashed result as much as a fresh commit — say so before writing it, not after. +3. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, token, password, connection string, or environment-specific config. Stop and flag it rather than committing it. -3. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. -4. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. -5. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. +4. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. +5. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. +6. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. ## Output diff --git a/plugins/git/.apm/skills/git-commits/references/rewrite-history.md b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md index 942f44f..88bc993 100644 --- a/plugins/git/.apm/skills/git-commits/references/rewrite-history.md +++ b/plugins/git/.apm/skills/git-commits/references/rewrite-history.md @@ -21,7 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark 1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit. 2. Get explicit approval — the rebase still rewrites history. -3. Run `rtk git rebase --autosquash HEAD~N`, or `-i --autosquash` to review the plan first. Git reorders the tagged commits against their targets automatically. +3. Run `rtk git rebase -i --autosquash HEAD~N`. Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply. + +**`-i` is not optional here.** On Git 2.39.5, `rtk git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that. ## Squash by hand (interactive rebase) @@ -35,3 +37,40 @@ Use this when the commits were not tagged at commit time. **Interactive rebase h ## When a rebase halts on a conflict Offer conflict resolution or `rtk git rebase --abort`. Do not resolve conflicts automatically without confirmation. + +## Rebase the branch onto a new base + +Replays this branch's commits on top of another branch's tip — bringing a feature branch up to +date without a merge commit. + +1. Confirm nothing being replayed has been pushed anywhere someone else has based work on. A rebase + gives every replayed commit a new SHA, which breaks any clone that already has the old ones. +2. Get explicit approval — this rewrites history like every other flow on this page. +3. `rtk git fetch origin` first, so `<newbase>` is the real tip rather than a stale local copy. +4. `rtk git rebase <newbase>` — for example `rtk git rebase main`. Use + `rtk git rebase --onto <newbase> <upstream> <branch>` to replay only the commits after + `<upstream>`, which is how a branch started from the wrong base gets moved. +5. The branch has now diverged from its remote. It needs + `--force-with-lease --force-if-includes` to push, never a bare `--force`, and never on + `main`/`master` — refuse that and explain. + +## Move the branch pointer back (`git reset`) + +`reset` moves the current branch to another commit. The mode decides what survives: + +| Mode | Branch pointer | Index | Working tree | +|---|---|---|---| +| `--soft` | moves | untouched — the changes stay staged | untouched | +| `--mixed` (default) | moves | reset — the changes become unstaged | untouched | +| `--hard` | moves | reset | **overwritten; uncommitted work is destroyed** | + +- "Undo my last commit but keep the changes" is `rtk git reset --soft HEAD~1`. That is the default + answer to the request; reach for anything else only when the caller asked for it. +- `rtk git reset --mixed HEAD~1` when the staging should be redone from scratch too. +- `rtk git reset --hard <ref>` is gated like a force-push: state exactly which uncommitted changes + will be discarded, get approval for that specific reset, and offer `rtk git stash push -u` first. + The commits it drops stay reachable through `git reflog`; the uncommitted edits never entered git + at all and nothing recovers them. + +Reset does not rewrite the commits it leaves behind, so no force-push is needed unless the branch +was already pushed at the newer commit. diff --git a/plugins/git/.apm/skills/git-history/README.md b/plugins/git/.apm/skills/git-history/README.md index 72aaa3b..e208cfa 100644 --- a/plugins/git/.apm/skills/git-history/README.md +++ b/plugins/git/.apm/skills/git-history/README.md @@ -8,7 +8,7 @@ This skill handles history inspection within the git workflow suite. It queries ## Composition -`git-branches` delegates cherry-pick and revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries those two operations rather than treating them as out of scope. They are general git knowledge, not drawn from the `history-inspection.md` research corpus. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. +`git-branches` delegates revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries that operation rather than treating it as out of scope; it is general git knowledge, not drawn from the `history-inspection.md` research corpus. Cherry-pick is **not** this skill's: `git-commits` owns it, and this skill's job ends at locating the SHA to hand over. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. ## Usage diff --git a/plugins/git/.apm/skills/git-history/SKILL.md b/plugins/git/.apm/skills/git-history/SKILL.md index bfb1fa9..6057bee 100644 --- a/plugins/git/.apm/skills/git-history/SKILL.md +++ b/plugins/git/.apm/skills/git-history/SKILL.md @@ -48,7 +48,7 @@ If you need the placeholder catalogue, format presets, `--diff-filter` letters, Offer the operation and its consequence; run it only once the user has chosen. -- `git cherry-pick <commit>` copies the commit's changes onto the current HEAD — for backporting a fix to another branch. +- Backporting the commit to another branch is a cherry-pick, and cherry-pick is `git-commits`' — it owns the destination-branch check, the `rtk git` wrapper and the `--abort` path. Hand it the SHA; do not run `git cherry-pick` from here. - `git revert <commit>` adds a new commit undoing it — for un-applying merged work without rewriting history. - `git blame <file>` attributes each line to the commit that last touched it, when the question is which commit introduced one specific line. diff --git a/plugins/git/.apm/skills/git-remotes/references/fetch.md b/plugins/git/.apm/skills/git-remotes/references/fetch.md index e0c05ff..0826b17 100644 --- a/plugins/git/.apm/skills/git-remotes/references/fetch.md +++ b/plugins/git/.apm/skills/git-remotes/references/fetch.md @@ -7,7 +7,9 @@ source_keys: # Fetching -Fetch updates remote-tracking branches (`refs/remotes/<name>/*`) and never modifies a local branch, so it is always safe to run. +Fetch **with no refspec** updates remote-tracking branches (`refs/remotes/<name>/*`) and leaves every local branch alone. + +That safety comes from the default refspec, not from `fetch` itself. Give it an explicit one and it writes to local branches: verified on Git 2.39.5, `git fetch origin main:probe` fast-forwarded the local `probe` branch, and a `+` prefix force-updates the destination, discarding whatever commits it held. Treat any `fetch` carrying a `<src>:<dst>` refspec as a branch update, not a read. - **One remote**: `git fetch <remote>` — all branches - **One branch**: `git fetch <remote> <branch>` — the result lands in `FETCH_HEAD`, not a tracking ref diff --git a/plugins/git/.apm/skills/git-remotes/references/pull.md b/plugins/git/.apm/skills/git-remotes/references/pull.md index 52be576..b769742 100644 --- a/plugins/git/.apm/skills/git-remotes/references/pull.md +++ b/plugins/git/.apm/skills/git-remotes/references/pull.md @@ -23,9 +23,11 @@ A pull that diverges with no strategy configured fails, and that failure is the ## Config precedence -The installed default varies by Git version — older versions merge on divergence, newer ones -default to `--ff-only` — so an unset `pull.ff` means the same pull behaves differently on different -machines. Set it explicitly. +`--ff-only` is not Git's default on an unset config, and never has been. Older versions silently +merged on divergence; current ones refuse outright — verified on Git 2.39.5, a divergent pull with +nothing configured prints the reconciliation hint and exits 128 with +`fatal: Need to specify how to reconcile divergent branches.` The behaviour therefore still varies +by installed version, and neither variant is the one you want. Set it explicitly. Highest wins: diff --git a/plugins/git/.apm/skills/git-submodules/SKILL.md b/plugins/git/.apm/skills/git-submodules/SKILL.md index 297f6a2..55d65f2 100644 --- a/plugins/git/.apm/skills/git-submodules/SKILL.md +++ b/plugins/git/.apm/skills/git-submodules/SKILL.md @@ -3,7 +3,8 @@ name: git-submodules description: > Use when managing Git submodules — the full lifecycle of a nested - repository inside a superproject. + repository inside a superproject — including phrasings that never say the + word, such as "add a dependency repo" or "vendor this repo inside ours". Not multiple checkouts of one repo -> `git-worktrees`. Not the superproject's own remotes -> `git-remotes`. diff --git a/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md index dfb9c75..23b0cb0 100644 --- a/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md +++ b/plugins/git/.apm/skills/git-submodules/references/setup-and-update.md @@ -70,8 +70,8 @@ rtk git submodule update --remote --merge --recursive rtk git commit -am "chore: update submodules to latest" ``` -`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default -branch. Commit the superproject afterwards or the new pin is lost on the next `update`. +`--remote` uses `submodule.<name>.branch` when it is set; without it Git falls back to the remote's +default branch. Commit the superproject afterwards or the new pin is lost on the next `update`. ## Run one command across every submodule diff --git a/plugins/git/.apm/skills/git-submodules/references/sources.md b/plugins/git/.apm/skills/git-submodules/references/sources.md index 41c8660..fd3adbc 100644 --- a/plugins/git/.apm/skills/git-submodules/references/sources.md +++ b/plugins/git/.apm/skills/git-submodules/references/sources.md @@ -22,10 +22,10 @@ source_keys: Other source keys extracted during the git plugin research phase inform sibling skills in the git workflow suite, not this one: -- `context7-git-htmldocs` — git:branches, git:history, git:remotes -- `git-scm-docs` — git:configuration -- `git-scm-worktree-docs` — git:worktrees -- `nvie-gitflow-post`, `atlassian-gitflow-tutorial`, `gitflow-cheatsheet` — git:branches -- `conventional-commits-spec`, `commitlint-config-conventional` — git:commits -- `git-scm-push-docs`, `git-scm-fetch-docs`, `git-scm-pull-docs`, `git-scm-remote-docs` — git:remotes -- `git-scm-bisect-docs`, `git-scm-log-docs`, `git-scm-diff-docs` — git:history +- `context7-git-htmldocs` — git-branches, git-history, git-remotes +- `git-scm-docs` — no current skill; it backed a git-configuration skill that no longer exists and survives here as provenance only +- `git-scm-worktree-docs` — git-worktrees +- `nvie-gitflow-post`, `atlassian-gitflow-tutorial`, `gitflow-cheatsheet` — git-branches +- `conventional-commits-spec`, `commitlint-config-conventional` — git-commits +- `git-scm-push-docs`, `git-scm-fetch-docs`, `git-scm-pull-docs`, `git-scm-remote-docs` — git-remotes +- `git-scm-bisect-docs`, `git-scm-log-docs`, `git-scm-diff-docs` — git-history diff --git a/plugins/git/.apm/skills/git-workflow/SKILL.md b/plugins/git/.apm/skills/git-workflow/SKILL.md index 40422fb..8f2f871 100644 --- a/plugins/git/.apm/skills/git-workflow/SKILL.md +++ b/plugins/git/.apm/skills/git-workflow/SKILL.md @@ -28,13 +28,16 @@ metadata: ## Domains -Every request resolves to exactly one of these six. An unambiguous one should have gone straight -to the domain skill; this skill exists for the ones that did not. +Route to the domain that owns the operation, and in sequence when a request spans two — a rebase +that ends in a force-push is `git-commits`, then `git-remotes`. An unambiguous request should have +gone straight to the domain skill; this one exists for the ones that did not. | The request is about | Domain | |---|---| | Writing, amending, squashing, or cherry-picking a commit, and its message | `git-commits` | +| Rebasing onto a new base, or undoing a commit with `reset` | `git-commits` | | Creating, switching, deleting, renaming, tracking, or merging a local branch | `git-branches` | +| Stashing work in progress to move between branches | `git-branches` | | When a change landed, which commit broke something, what to revert or backport | `git-history` | | Anything touching a remote — remote config, fetch, push, pull — even unnamed | `git-remotes` | | A nested repository pinned inside this one by a recorded commit | `git-submodules` | diff --git a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md index 64172f1..c4d9b22 100644 --- a/plugins/git/.apm/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/.apm/skills/git-worktrees/references/worktrees.md @@ -53,7 +53,7 @@ final argument of the `--track -b` form. | `-b <branch>` | Create and check out a new branch; fails if it exists | | `-B <branch>` | Like `-b` but resets the branch if it already exists | | `-d` / `--detach` | Detach HEAD; useful for throwaway experiments | -| `--orphan` | Create empty unborn branch | +| `--orphan` | Create empty unborn branch — **Git 2.42+**; earlier versions exit 129 with `error: unknown option 'orphan'`. Fallback below | | `--no-checkout` | Suppress initial checkout (for sparse-checkout setup) | | `--guess-remote` | Look for a matching remote-tracking branch by path basename | | `--lock [--reason <str>]` | Lock immediately on creation (atomic; avoids race vs. add-then-lock) | @@ -67,7 +67,22 @@ Using `-` as `<commit-ish>` is shorthand for `@{-1}` (the branch checked out bef ```bash git worktree add --orphan -b <branch> <path> ``` -Creates an empty branch with no commits. +Creates an empty branch with no commits. **`--orphan` needs Git 2.42 or later** — it was added +upstream in 2.42, and on 2.39.5 this fails with `error: unknown option 'orphan'` and exit 129. +Check `git --version` before reaching for it. + +Fallback on older Git, verified on 2.39.5 — detach first, then orphan the linked worktree in place, +which leaves the main worktree on its own branch throughout: + +```bash +git worktree add -d <path> # linked worktree, detached HEAD +cd <path> +git switch --orphan <branch> # unborn branch: empty index, empty working tree +``` + +`git worktree list` then shows the new worktree at `0000000 [<branch>]` until its first commit. +Do not substitute `git switch --orphan` in the *main* worktree: it clears that checkout, which is +the disruption worktrees exist to avoid. ## Sparse-checkout worktree diff --git a/plugins/git/agents/git-orchestrate.agent.md b/plugins/git/agents/git-orchestrate.agent.md index a847abb..f4b68d5 100644 --- a/plugins/git/agents/git-orchestrate.agent.md +++ b/plugins/git/agents/git-orchestrate.agent.md @@ -16,7 +16,7 @@ You are the orchestrator for the git plugin—a composable workflow dispatcher d You act on the caller's real branch and session context (you explicitly carry forward `current_branch`), not a disposable copy — you do not run in an isolated worktree. -**Scope:** this orchestrator routes git-object operations only (commits, branches, worktrees, remotes, submodules, history). `pc-author` and `pc-run` (pre-commit config authoring and hook execution) are intentionally not routed here — they operate on `.pre-commit-config.yaml` and hook installation, not git objects. `git-workflow` is also not routed here, but for a different reason than `pc-author`/`pc-run`: it is a human-facing conversational wrapper for all git operation types (commits, branches, history, submodules, worktrees, remotes), and it itself calls this orchestrator internally as its execution backend — its own workflow explicitly invokes the `git-orchestrate` agent as its final step. It is not a peer to invoke instead of this dispatcher, and it explicitly refuses agent callers ("Do not use when the caller is an agent"). Agent callers route git-object operations here directly; direct human users to `git-workflow` when they want guided, conversational git help — it will call back into this orchestrator itself. Invoke `pc-author`/`pc-run` directly rather than through this dispatcher; do not invoke `git-workflow` as an agent caller under any circumstance. +**Scope:** this orchestrator routes git-object operations only (commits, branches, worktrees, remotes, submodules, history). `pc-author` and `pc-run` (pre-commit config authoring and hook execution) are intentionally not routed here — they operate on `.pre-commit-config.yaml` and hook installation, not git objects. `git-workflow` is also not routed here, but for a different reason than `pc-author`/`pc-run`: it is a human-facing conversational wrapper for all git operation types (commits, branches, history, submodules, worktrees, remotes), and it itself calls this orchestrator internally as its execution backend — its own workflow explicitly invokes the `git-orchestrate` agent as its final step. It is not a peer to invoke instead of this dispatcher, and its own boundary clause sends agent callers here ("Not an agent caller -> `git-orchestrate`"). Agent callers route git-object operations here directly; direct human users to `git-workflow` when they want guided, conversational git help — it will call back into this orchestrator itself. Invoke `pc-author`/`pc-run` directly rather than through this dispatcher; do not invoke `git-workflow` as an agent caller under any circumstance. ## Hard rules diff --git a/plugins/git/skills/git-branches/README.md b/plugins/git/skills/git-branches/README.md index b3626dd..5c4f104 100644 --- a/plugins/git/skills/git-branches/README.md +++ b/plugins/git/skills/git-branches/README.md @@ -20,7 +20,7 @@ Describe your branch task: create a feature/hotfix/release branch, switch, delet |------|---------| | `SKILL.md` | Skill instructions for agents | | `references/branch-patterns.md` | Loaded when a branch's base, name prefix, or merge rule depends on GitHub Flow vs. Gitflow | -| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list action, or resolving `get-intent` | +| `references/branch-operations.md` | Loaded when running a create/switch/delete/rename/track/list/stash action, or resolving `get-intent` | | `references/merging.md` | Loaded when merging one branch into another or resolving merge conflicts | | `references/comparing-branches.md` | Loaded when comparing two branches or finding where they diverged | | `references/orchestrator-contract.md` | Loaded when `git-orchestrate` or another calling agent supplies a structured request rather than prose | @@ -29,5 +29,6 @@ Describe your branch task: create a feature/hotfix/release branch, switch, delet ## Composition `git-orchestrate` calls this skill for the branch step of a multi-step workflow and parses its -structured result. Cherry-pick and revert are `git-history`'s; commit authoring and rebase are -`git-commits`'; branch operations against a Gitea-hosted remote are `gitea-branches`'. +structured result. Revert is `git-history`'s; commit authoring, rebase, reset and cherry-pick are +`git-commits`'; deleting a remote branch is `git-remotes`'; branch operations against a +Gitea-hosted remote are `gitea-branches`'. diff --git a/plugins/git/skills/git-branches/SKILL.md b/plugins/git/skills/git-branches/SKILL.md index ca98aaf..44d79c9 100644 --- a/plugins/git/skills/git-branches/SKILL.md +++ b/plugins/git/skills/git-branches/SKILL.md @@ -33,7 +33,7 @@ The two patterns are not mixable, and the wrong merge rule silently damages hist | Action | Reference | |---|---| -| create, switch, delete, rename, track, list, get-intent | `references/branch-operations.md` | +| create, switch, delete, rename, track, list, get-intent, stash | `references/branch-operations.md` | | merge a branch, resolve merge conflicts | `references/merging.md` | | compare two branches, find their divergence | `references/comparing-branches.md` | @@ -51,7 +51,7 @@ These gates are passable. The `main`/`master` refusal in Gotchas is not. ## Step 4 — Set tracking -When pushing a branch for the first time, always `git push -u origin <branch>`. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. +A new branch's first push must be `git push -u origin <branch>`. The push itself is `git-remotes`' — every remote-side gate lives there, which is why Step 2's remote-delete row hands off the same way — but the upstream requirement originates here, so carry it in the handoff. Without an upstream, later pushes and pulls either fail or silently target the wrong remote branch, and the caller has no way to tell which happened. ## Step 5 — Return a structured result diff --git a/plugins/git/skills/git-branches/references/branch-operations.md b/plugins/git/skills/git-branches/references/branch-operations.md index 8c428cc..b9e29f4 100644 --- a/plugins/git/skills/git-branches/references/branch-operations.md +++ b/plugins/git/skills/git-branches/references/branch-operations.md @@ -15,7 +15,9 @@ hatch. - **delete (local)** — `git branch -d <branch>` refuses when the branch holds unmerged commits, which is why it is the default. `git branch -D <branch>` forces the deletion and discards that work — only after the destructive-operation gates pass and `confirm: true` is set. -- **delete (remote)** — `git push origin --delete <branch>`. +- **delete (remote)** — not this skill's. Deleting a remote branch is a push, and every remote-side + gate lives in `git-remotes`; hand it there rather than running the push from here. Its + `references/push.md` carries the command and the refspec form. - **rename** — `git branch -m <old> <new>`. - **list** — `git branch` (local), `-a` (local plus remote-tracking), `-r` (remote-tracking only), `--merged` / `--no-merged` (filter by merge status into the current branch). @@ -32,3 +34,23 @@ On `get-intent`, either parse the intent back out of the branch-name convention (`feature/<intent-slug>`) or return `{ "intent": null }` when the caller never persisted the create-time value. Never fabricate an intent: a downstream commit message built on a guessed intent is worse than one built on none. + +## Stashing work in progress + +A switch aborts rather than clobbering conflicting local changes (see Gotchas). Stash is the way +past it: it shelves the working tree and index so the branch pointer can move. + +- **save** — `git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git + 2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes + reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message. +- **restore** — `git stash pop` applies the newest entry and deletes it. `git stash apply stash@{n}` + applies without deleting, for replaying one shelf onto more than one branch. +- **list** — `git stash list`; `git stash show -p stash@{n}` prints that entry's diff. +- **drop** — `git stash drop stash@{n}` deletes one entry. `git stash clear` deletes all of them + and nothing recovers them — confirm before running it. +- **branch from a stash** — `git stash branch <branch> stash@{n}` creates a branch at the commit the + stash was taken from and pops it there. Use it when the stash no longer applies to the current tip. + +**A conflicting `pop` keeps the entry.** Verified on 2.39.5: it exits 1, writes conflict markers, +prints "The stash entry is kept in case you need it again", and `git stash list` still shows it. +Resolve, `git add`, then `git stash drop` the entry by hand — otherwise it silently accumulates. diff --git a/plugins/git/skills/git-branches/references/merging.md b/plugins/git/skills/git-branches/references/merging.md index d1721e3..0302138 100644 --- a/plugins/git/skills/git-branches/references/merging.md +++ b/plugins/git/skills/git-branches/references/merging.md @@ -6,8 +6,8 @@ source_keys: # Merging one branch into another -Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase belongs to -`git-commits`; cherry-pick and revert to `git-history`. +Scope is fast-forward and merge-commit mechanics plus conflict resolution. Rebase and cherry-pick +belong to `git-commits`; revert to `git-history`. - **Fast-forward** — `git merge <branch>` advances the pointer with no merge commit when the target has not diverged. diff --git a/plugins/git/skills/git-commits/SKILL.md b/plugins/git/skills/git-commits/SKILL.md index 3b9683c..59ca602 100644 --- a/plugins/git/skills/git-commits/SKILL.md +++ b/plugins/git/skills/git-commits/SKILL.md @@ -8,6 +8,7 @@ description: > Not branch lifecycle -> `git-branches`. metadata: + version: "0.1.3" category: git source_keys: - conventional-commits-spec @@ -22,6 +23,7 @@ allowed-tools: Bash - **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. - **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it. +- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first. - **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning. ## Dispatch @@ -31,18 +33,19 @@ Read exactly one flow file. Each is self-contained. | Condition | Flow | Read | |---|---|---| | Composing a new commit from staged changes | create | `references/create-commit.md` | -| Amending, squashing, or folding a fixup into an earlier commit | rewrite | `references/rewrite-history.md` | +| Amending, squashing, folding a fixup, rebasing onto a new base, or resetting HEAD | rewrite | `references/rewrite-history.md` | | Replaying an existing commit onto the current branch | cherry-pick | `references/cherry-pick.md` | ## Gates on every flow 1. **Confirmation.** No history rewrite executes without explicit approval from the user or the calling agent. Cherry-pick needs the destination branch confirmed first. -2. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, +2. **Atomicity.** The result must be one logical, independently reviewable and reversible change that leaves the repository buildable and testable. This binds an amend or a squashed result as much as a fresh commit — say so before writing it, not after. +3. **Secrets.** Before any commit or amend, scan the staged diff for anything resembling an API key, token, password, connection string, or environment-specific config. Stop and flag it rather than committing it. -3. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. -4. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. -5. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. +4. **Validation.** Check the message against commitlint `config-conventional` before committing. If a type, footer, or breaking-change edge case is not obvious, read `references/conventional-commits-spec.md` — it carries the constraint table, the 11-type set, and the footer token rules. +5. **SemVer impact.** Report the bump the commit implies: `feat` → MINOR, `fix`/`perf`/`revert` → PATCH, any breaking change → MAJOR, everything else → none. Callers decide releases from this, so never omit it. +6. **Conflicts.** If a rebase or cherry-pick halts, offer resolution or an abort. Do not resolve automatically without confirmation. ## Output diff --git a/plugins/git/skills/git-commits/references/rewrite-history.md b/plugins/git/skills/git-commits/references/rewrite-history.md index 942f44f..88bc993 100644 --- a/plugins/git/skills/git-commits/references/rewrite-history.md +++ b/plugins/git/skills/git-commits/references/rewrite-history.md @@ -21,7 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark 1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit. 2. Get explicit approval — the rebase still rewrites history. -3. Run `rtk git rebase --autosquash HEAD~N`, or `-i --autosquash` to review the plan first. Git reorders the tagged commits against their targets automatically. +3. Run `rtk git rebase -i --autosquash HEAD~N`. Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply. + +**`-i` is not optional here.** On Git 2.39.5, `rtk git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that. ## Squash by hand (interactive rebase) @@ -35,3 +37,40 @@ Use this when the commits were not tagged at commit time. **Interactive rebase h ## When a rebase halts on a conflict Offer conflict resolution or `rtk git rebase --abort`. Do not resolve conflicts automatically without confirmation. + +## Rebase the branch onto a new base + +Replays this branch's commits on top of another branch's tip — bringing a feature branch up to +date without a merge commit. + +1. Confirm nothing being replayed has been pushed anywhere someone else has based work on. A rebase + gives every replayed commit a new SHA, which breaks any clone that already has the old ones. +2. Get explicit approval — this rewrites history like every other flow on this page. +3. `rtk git fetch origin` first, so `<newbase>` is the real tip rather than a stale local copy. +4. `rtk git rebase <newbase>` — for example `rtk git rebase main`. Use + `rtk git rebase --onto <newbase> <upstream> <branch>` to replay only the commits after + `<upstream>`, which is how a branch started from the wrong base gets moved. +5. The branch has now diverged from its remote. It needs + `--force-with-lease --force-if-includes` to push, never a bare `--force`, and never on + `main`/`master` — refuse that and explain. + +## Move the branch pointer back (`git reset`) + +`reset` moves the current branch to another commit. The mode decides what survives: + +| Mode | Branch pointer | Index | Working tree | +|---|---|---|---| +| `--soft` | moves | untouched — the changes stay staged | untouched | +| `--mixed` (default) | moves | reset — the changes become unstaged | untouched | +| `--hard` | moves | reset | **overwritten; uncommitted work is destroyed** | + +- "Undo my last commit but keep the changes" is `rtk git reset --soft HEAD~1`. That is the default + answer to the request; reach for anything else only when the caller asked for it. +- `rtk git reset --mixed HEAD~1` when the staging should be redone from scratch too. +- `rtk git reset --hard <ref>` is gated like a force-push: state exactly which uncommitted changes + will be discarded, get approval for that specific reset, and offer `rtk git stash push -u` first. + The commits it drops stay reachable through `git reflog`; the uncommitted edits never entered git + at all and nothing recovers them. + +Reset does not rewrite the commits it leaves behind, so no force-push is needed unless the branch +was already pushed at the newer commit. diff --git a/plugins/git/skills/git-history/README.md b/plugins/git/skills/git-history/README.md index 72aaa3b..e208cfa 100644 --- a/plugins/git/skills/git-history/README.md +++ b/plugins/git/skills/git-history/README.md @@ -8,7 +8,7 @@ This skill handles history inspection within the git workflow suite. It queries ## Composition -`git-branches` delegates cherry-pick and revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries those two operations rather than treating them as out of scope. They are general git knowledge, not drawn from the `history-inspection.md` research corpus. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. +`git-branches` delegates revert here (see `git-branches`'s `references/merging.md`), which is why this skill carries that operation rather than treating it as out of scope; it is general git knowledge, not drawn from the `history-inspection.md` research corpus. Cherry-pick is **not** this skill's: `git-commits` owns it, and this skill's job ends at locating the SHA to hand over. Server-side commit history on a Gitea-hosted repository belongs to `gitea-branches`; this skill reads the local working copy. ## Usage diff --git a/plugins/git/skills/git-history/SKILL.md b/plugins/git/skills/git-history/SKILL.md index bfb1fa9..6057bee 100644 --- a/plugins/git/skills/git-history/SKILL.md +++ b/plugins/git/skills/git-history/SKILL.md @@ -48,7 +48,7 @@ If you need the placeholder catalogue, format presets, `--diff-filter` letters, Offer the operation and its consequence; run it only once the user has chosen. -- `git cherry-pick <commit>` copies the commit's changes onto the current HEAD — for backporting a fix to another branch. +- Backporting the commit to another branch is a cherry-pick, and cherry-pick is `git-commits`' — it owns the destination-branch check, the `rtk git` wrapper and the `--abort` path. Hand it the SHA; do not run `git cherry-pick` from here. - `git revert <commit>` adds a new commit undoing it — for un-applying merged work without rewriting history. - `git blame <file>` attributes each line to the commit that last touched it, when the question is which commit introduced one specific line. diff --git a/plugins/git/skills/git-remotes/references/fetch.md b/plugins/git/skills/git-remotes/references/fetch.md index e0c05ff..0826b17 100644 --- a/plugins/git/skills/git-remotes/references/fetch.md +++ b/plugins/git/skills/git-remotes/references/fetch.md @@ -7,7 +7,9 @@ source_keys: # Fetching -Fetch updates remote-tracking branches (`refs/remotes/<name>/*`) and never modifies a local branch, so it is always safe to run. +Fetch **with no refspec** updates remote-tracking branches (`refs/remotes/<name>/*`) and leaves every local branch alone. + +That safety comes from the default refspec, not from `fetch` itself. Give it an explicit one and it writes to local branches: verified on Git 2.39.5, `git fetch origin main:probe` fast-forwarded the local `probe` branch, and a `+` prefix force-updates the destination, discarding whatever commits it held. Treat any `fetch` carrying a `<src>:<dst>` refspec as a branch update, not a read. - **One remote**: `git fetch <remote>` — all branches - **One branch**: `git fetch <remote> <branch>` — the result lands in `FETCH_HEAD`, not a tracking ref diff --git a/plugins/git/skills/git-remotes/references/pull.md b/plugins/git/skills/git-remotes/references/pull.md index 52be576..b769742 100644 --- a/plugins/git/skills/git-remotes/references/pull.md +++ b/plugins/git/skills/git-remotes/references/pull.md @@ -23,9 +23,11 @@ A pull that diverges with no strategy configured fails, and that failure is the ## Config precedence -The installed default varies by Git version — older versions merge on divergence, newer ones -default to `--ff-only` — so an unset `pull.ff` means the same pull behaves differently on different -machines. Set it explicitly. +`--ff-only` is not Git's default on an unset config, and never has been. Older versions silently +merged on divergence; current ones refuse outright — verified on Git 2.39.5, a divergent pull with +nothing configured prints the reconciliation hint and exits 128 with +`fatal: Need to specify how to reconcile divergent branches.` The behaviour therefore still varies +by installed version, and neither variant is the one you want. Set it explicitly. Highest wins: diff --git a/plugins/git/skills/git-submodules/SKILL.md b/plugins/git/skills/git-submodules/SKILL.md index 297f6a2..55d65f2 100644 --- a/plugins/git/skills/git-submodules/SKILL.md +++ b/plugins/git/skills/git-submodules/SKILL.md @@ -3,7 +3,8 @@ name: git-submodules description: > Use when managing Git submodules — the full lifecycle of a nested - repository inside a superproject. + repository inside a superproject — including phrasings that never say the + word, such as "add a dependency repo" or "vendor this repo inside ours". Not multiple checkouts of one repo -> `git-worktrees`. Not the superproject's own remotes -> `git-remotes`. diff --git a/plugins/git/skills/git-submodules/references/setup-and-update.md b/plugins/git/skills/git-submodules/references/setup-and-update.md index dfb9c75..23b0cb0 100644 --- a/plugins/git/skills/git-submodules/references/setup-and-update.md +++ b/plugins/git/skills/git-submodules/references/setup-and-update.md @@ -70,8 +70,8 @@ rtk git submodule update --remote --merge --recursive rtk git commit -am "chore: update submodules to latest" ``` -`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default -branch. Commit the superproject afterwards or the new pin is lost on the next `update`. +`--remote` uses `submodule.<name>.branch` when it is set; without it Git falls back to the remote's +default branch. Commit the superproject afterwards or the new pin is lost on the next `update`. ## Run one command across every submodule diff --git a/plugins/git/skills/git-submodules/references/sources.md b/plugins/git/skills/git-submodules/references/sources.md index 41c8660..fd3adbc 100644 --- a/plugins/git/skills/git-submodules/references/sources.md +++ b/plugins/git/skills/git-submodules/references/sources.md @@ -22,10 +22,10 @@ source_keys: Other source keys extracted during the git plugin research phase inform sibling skills in the git workflow suite, not this one: -- `context7-git-htmldocs` — git:branches, git:history, git:remotes -- `git-scm-docs` — git:configuration -- `git-scm-worktree-docs` — git:worktrees -- `nvie-gitflow-post`, `atlassian-gitflow-tutorial`, `gitflow-cheatsheet` — git:branches -- `conventional-commits-spec`, `commitlint-config-conventional` — git:commits -- `git-scm-push-docs`, `git-scm-fetch-docs`, `git-scm-pull-docs`, `git-scm-remote-docs` — git:remotes -- `git-scm-bisect-docs`, `git-scm-log-docs`, `git-scm-diff-docs` — git:history +- `context7-git-htmldocs` — git-branches, git-history, git-remotes +- `git-scm-docs` — no current skill; it backed a git-configuration skill that no longer exists and survives here as provenance only +- `git-scm-worktree-docs` — git-worktrees +- `nvie-gitflow-post`, `atlassian-gitflow-tutorial`, `gitflow-cheatsheet` — git-branches +- `conventional-commits-spec`, `commitlint-config-conventional` — git-commits +- `git-scm-push-docs`, `git-scm-fetch-docs`, `git-scm-pull-docs`, `git-scm-remote-docs` — git-remotes +- `git-scm-bisect-docs`, `git-scm-log-docs`, `git-scm-diff-docs` — git-history diff --git a/plugins/git/skills/git-workflow/SKILL.md b/plugins/git/skills/git-workflow/SKILL.md index 40422fb..8f2f871 100644 --- a/plugins/git/skills/git-workflow/SKILL.md +++ b/plugins/git/skills/git-workflow/SKILL.md @@ -28,13 +28,16 @@ metadata: ## Domains -Every request resolves to exactly one of these six. An unambiguous one should have gone straight -to the domain skill; this skill exists for the ones that did not. +Route to the domain that owns the operation, and in sequence when a request spans two — a rebase +that ends in a force-push is `git-commits`, then `git-remotes`. An unambiguous request should have +gone straight to the domain skill; this one exists for the ones that did not. | The request is about | Domain | |---|---| | Writing, amending, squashing, or cherry-picking a commit, and its message | `git-commits` | +| Rebasing onto a new base, or undoing a commit with `reset` | `git-commits` | | Creating, switching, deleting, renaming, tracking, or merging a local branch | `git-branches` | +| Stashing work in progress to move between branches | `git-branches` | | When a change landed, which commit broke something, what to revert or backport | `git-history` | | Anything touching a remote — remote config, fetch, push, pull — even unnamed | `git-remotes` | | A nested repository pinned inside this one by a recorded commit | `git-submodules` | diff --git a/plugins/git/skills/git-worktrees/references/worktrees.md b/plugins/git/skills/git-worktrees/references/worktrees.md index 64172f1..c4d9b22 100644 --- a/plugins/git/skills/git-worktrees/references/worktrees.md +++ b/plugins/git/skills/git-worktrees/references/worktrees.md @@ -53,7 +53,7 @@ final argument of the `--track -b` form. | `-b <branch>` | Create and check out a new branch; fails if it exists | | `-B <branch>` | Like `-b` but resets the branch if it already exists | | `-d` / `--detach` | Detach HEAD; useful for throwaway experiments | -| `--orphan` | Create empty unborn branch | +| `--orphan` | Create empty unborn branch — **Git 2.42+**; earlier versions exit 129 with `error: unknown option 'orphan'`. Fallback below | | `--no-checkout` | Suppress initial checkout (for sparse-checkout setup) | | `--guess-remote` | Look for a matching remote-tracking branch by path basename | | `--lock [--reason <str>]` | Lock immediately on creation (atomic; avoids race vs. add-then-lock) | @@ -67,7 +67,22 @@ Using `-` as `<commit-ish>` is shorthand for `@{-1}` (the branch checked out bef ```bash git worktree add --orphan -b <branch> <path> ``` -Creates an empty branch with no commits. +Creates an empty branch with no commits. **`--orphan` needs Git 2.42 or later** — it was added +upstream in 2.42, and on 2.39.5 this fails with `error: unknown option 'orphan'` and exit 129. +Check `git --version` before reaching for it. + +Fallback on older Git, verified on 2.39.5 — detach first, then orphan the linked worktree in place, +which leaves the main worktree on its own branch throughout: + +```bash +git worktree add -d <path> # linked worktree, detached HEAD +cd <path> +git switch --orphan <branch> # unborn branch: empty index, empty working tree +``` + +`git worktree list` then shows the new worktree at `0000000 [<branch>]` until its first commit. +Do not substitute `git switch --orphan` in the *main* worktree: it clears that checkout, which is +the disruption worktrees exist to avoid. ## Sparse-checkout worktree -- 2.43.0 From be9b8d277fefad8740f00e4753b653a5211d9141 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:38:53 +0000 Subject: [PATCH 84/89] fix(gitea): restore the withLines exception and route rename_branch through the orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitea-files' always-loaded Gotchas said "content is base64 both ways" without qualification. The `main` text carried an exception for `withLines: true` and both halves were dropped. Verified live: `get_file_contents` with `withLines: true` returns plain JSON text while the same response still reports `"encoding":"base64"`. An agent that follows the recommendation two sentences later and applies the unconditional decode gets garbage, with the response's own field confirming the wrong answer. Exception restored, and the lying field named. gitea-orchestrate was never updated for `rename_branch`: absent from the operation enum, so an agent caller got "unknown operation", and absent from the destructive-confirm list, though branches.md requires a rename with open PRs or a protection rule to be confirmed exactly as `delete_branch` is. Added to both — the confirm gate rather than the enum alone, because accepting the operation without it routes around a rule the skill states while appearing to support it. The compatibility frontmatter, which the agent reads, still omitted the tool too. Two more always-loaded Gotchas contradicted their own reference files, and the Gotcha was wrong both times: issues and PRs are distinguishable on a list item by the `html_url` path segment (confirmed live — #129 at /pulls/, #128 at /issues/), and `get_repository_tree` takes `tree_sha`, not `ref`. `review_comments` was asserted as unconditionally present on the PR get response. It is absent on a PR with no review comments, so the claim is downgraded to present-when-non-zero rather than stated as response shape. The label-exclusivity relocation moved the rule out of label-inference.md and into labels.md without updating sources.md, leaving the one rule in this branch that writes differently to live repos citing a file that no longer carries it. The rule itself is correct as it stands and `main` was wrong — every Kind/* label on this instance is exclusive:false, every Priority/* and Status/* is true — so only the provenance record is corrected. Routing: gitea-workflow lost the human-caller discriminator and widened from status checks to any request, which sent "close #42" to a branch that resolves the number and presents detail without ever closing it. gitea-branches and gitea-issues regain trigger phrasings the retrofit dropped. Refs: #92 --- .../gitea/.apm/agents/gitea-orchestrate.agent.md | 7 ++++--- plugins/gitea/.apm/skills/gitea-branches/SKILL.md | 5 +++-- plugins/gitea/.apm/skills/gitea-files/SKILL.md | 4 ++-- .../.apm/skills/gitea-files/references/reading.md | 10 +++++++--- plugins/gitea/.apm/skills/gitea-issues/SKILL.md | 6 +++--- .../gitea-labels-milestones/references/labels.md | 1 + .../gitea-labels-milestones/references/sources.md | 2 +- .../skills/gitea-prs/references/pull-requests.md | 2 +- .../.apm/skills/gitea-prs/references/reviews.md | 2 +- plugins/gitea/.apm/skills/gitea-workflow/SKILL.md | 14 ++++++++------ .../gitea-workflow/references/number-resolution.md | 2 ++ plugins/gitea/agents/gitea-orchestrate.agent.md | 7 ++++--- plugins/gitea/skills/gitea-branches/SKILL.md | 5 +++-- plugins/gitea/skills/gitea-files/SKILL.md | 4 ++-- .../gitea/skills/gitea-files/references/reading.md | 10 +++++++--- plugins/gitea/skills/gitea-issues/SKILL.md | 6 +++--- .../gitea-labels-milestones/references/labels.md | 1 + .../gitea-labels-milestones/references/sources.md | 2 +- .../skills/gitea-prs/references/pull-requests.md | 2 +- .../gitea/skills/gitea-prs/references/reviews.md | 2 +- plugins/gitea/skills/gitea-workflow/SKILL.md | 14 ++++++++------ .../gitea-workflow/references/number-resolution.md | 2 ++ 22 files changed, 66 insertions(+), 44 deletions(-) diff --git a/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md b/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md index 45f93ce..50e3493 100644 --- a/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md +++ b/plugins/gitea/.apm/agents/gitea-orchestrate.agent.md @@ -23,6 +23,7 @@ You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) an These are non-negotiable regardless of `confirm` or any skill-local override: - Never delete the repository's default branch (typically `main` or `master`) — refused outright, independent of `confirm`. - `delete_release` takes a numeric `id`; `delete_tag` takes a `tag_name` string. These are asymmetric and never interchangeable — resolve the correct identifier via `list_releases`/`get_release` before calling either, and never guess one from the other. +- `rename-branch` is gated like a delete even though it destroys nothing: what a rename does to open PRs using the branch as head or base, to a matching protection rule, and to every other clone's tracking branch is unconfirmed by `gitea-branches`' sources. Require `confirm: true`, and verify the PR and protection sides afterwards. - Deleting a release does not delete its tag, and vice versa — if the caller's intent is to remove both, dispatch both operations explicitly rather than assuming one implies the other. - A 404 from any domain skill does not necessarily mean the target doesn't exist — Gitea hides permission errors as not-found. Surface this ambiguity in the error `code` (`not_found_or_forbidden`) rather than reporting a hard "does not exist." - Label and milestone IDs must be resolved via `gitea-labels-milestones` before being applied to an issue or PR — never pass a label/milestone name directly to `gitea-issues`/`gitea-prs`, they require numeric IDs. @@ -43,7 +44,7 @@ Sub-skills carry their own local copies of relevant gotchas for humans who invok When invoked, you: 1. Parse the incoming workflow request (operation type, parameters, context overrides) -2. Check safety gates: if the operation is destructive (delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) and the request lacks explicit `confirm: true`, fail immediately with "requires explicit confirmation"; deleting the default branch is refused outright regardless of `confirm` +2. Check safety gates: if the operation is destructive (rename-branch, delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) and the request lacks explicit `confirm: true`, fail immediately with "requires explicit confirmation"; deleting the default branch is refused outright regardless of `confirm` 3. Route to the appropriate domain skill: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases` 4. Manage session context: resolve and carry forward `owner`/`repo` and any cached number-space resolutions, passing them explicitly to each skill 5. Handle error recovery: for recoverable failures (rate limiting, transient 5xx, pagination gaps) retry or complete the operation; for ambiguous 404s, attempt the permission-vs-not-found disambiguation before failing @@ -55,12 +56,12 @@ When invoked, you: - issues: list-issues, get-issue, create-issue, update-issue, comment-issue, search-issues - labels/milestones: list-labels, create-label, update-label, delete-label, list-milestones, create-milestone, update-milestone, close-milestone, delete-milestone, resolve-labels - prs: list-prs, get-pr, create-pr, update-pr, close-pr, reopen-pr, merge-pr, review-pr - - branches/commits: list-branches, create-branch, delete-branch, list-commits, get-commit + - branches/commits: list-branches, create-branch, rename-branch, delete-branch, list-commits, get-commit - files: get-file, get-dir, get-tree, write-file, delete-file - releases/tags: list-releases, get-release, create-release, delete-release, list-tags, create-tag, delete-tag - **parameters:** object, operation-specific arguments (issue/PR number, title, body, label names, tag name, file path, etc.) - **context:** object (optional), session state to carry forward (`owner`, `repo`, cached number-space resolutions) -- **confirm:** boolean (optional), explicit confirmation for destructive operations (required if not set for delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) +- **confirm:** boolean (optional), explicit confirmation for destructive operations (required if not set for rename-branch, delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) ## Process diff --git a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md index 4645153..a0a3a0d 100644 --- a/plugins/gitea/.apm/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-branches/SKILL.md @@ -3,11 +3,12 @@ name: gitea-branches description: > Use when listing, creating, renaming, or deleting branches in a Gitea repository, - or reading its commit history — even when the user does not say "Gitea". Not a + or reading its commit history — "what commits are on this branch", "what changed + in that commit" — even when the user does not say "Gitea". Not a local checkout's branches -> `git-branches`. Not local history -> `git-history`. Not a PR's head or base -> `gitea-prs`. -compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. +compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate rename_branch, list_commits, and get_commit. Requires git remote "origin" pointing to the Gitea instance. metadata: category: integration diff --git a/plugins/gitea/.apm/skills/gitea-files/SKILL.md b/plugins/gitea/.apm/skills/gitea-files/SKILL.md index 401952a..168e875 100644 --- a/plugins/gitea/.apm/skills/gitea-files/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-files/SKILL.md @@ -23,8 +23,8 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Gotchas - **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. -- **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. -- **`content` is base64 both ways.** Encode before a write, decode after a read. +- **Reads take `ref` (`tree_sha` on `get_repository_tree`), writes take `branch_name`.** One concept, three names — carry the wrong key and the branch is dropped. +- **`content` is base64 both ways — except under `withLines: true`.** Encode before a write, decode after a read; but with `withLines: true` `content` is already plain JSON text and the reported `"encoding": "base64"` is a lie. Decoding it yields garbage. ## Inputs diff --git a/plugins/gitea/.apm/skills/gitea-files/references/reading.md b/plugins/gitea/.apm/skills/gitea-files/references/reading.md index ca1847a..db2aa15 100644 --- a/plugins/gitea/.apm/skills/gitea-files/references/reading.md +++ b/plugins/gitea/.apm/skills/gitea-files/references/reading.md @@ -14,9 +14,13 @@ All three read calls select what to read with `ref` — a branch name, tag, or c `get_file_contents(owner, repo, ref, path)`. The response carries the file's `sha` at the **top level**, not nested under `content`. That field -is the write-ready SHA, so capture it whenever a write may follow. Content comes back -base64-encoded — decode it. Pass `withLines: true` only when you need numbered lines to quote -specific lines back to the user; omit it for a normal content fetch. +is the write-ready SHA, so capture it whenever a write may follow. + +Content comes back base64-encoded — decode it — **unless `withLines: true` was passed**, in which +case `content` is already plain text: a JSON array of `{"line": N, "content": "..."}` objects. +The response reports `"encoding": "base64"` either way, so that field is wrong under `withLines` +and decoding on its word yields garbage. Pass `withLines: true` only when you need numbered lines +to quote specific lines back to the user; omit it for a normal content fetch. ## One directory level diff --git a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md index 72754b5..a210459 100644 --- a/plugins/gitea/.apm/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-issues/SKILL.md @@ -3,8 +3,8 @@ name: gitea-issues description: > 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`. + "close issue #N", "comment on issue #N", "label 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`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token @@ -26,7 +26,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one number space; pass `type: "issues"` to exclude PRs (or `"pulls"`). `is_pull` is returned only by `issue_read method: "get"` — on a list item the only tell is `html_url`'s path segment (`/issues/` vs `/pulls/`). - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. - **A merge does not itself close the issue.** Gitea has no close-on-merge event, but a `Fixes #N` in the merged commits can, depending on merge style (`gitea-prs`). Re-read its state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md index 8188386..172cea2 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/labels.md @@ -3,6 +3,7 @@ topic: labels source_keys: - gitea-mcp-repo - gitea-mcp-slim-go + - context7-websites-gitea --- # Label operations diff --git a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/sources.md b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/sources.md index 0315aac..22db893 100644 --- a/plugins/gitea/.apm/skills/gitea-labels-milestones/references/sources.md +++ b/plugins/gitea/.apm/skills/gitea-labels-milestones/references/sources.md @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 (docs.gitea.com content) — scoped/exclusive label conventions and milestone/label state-transition semantics - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md, references/label-inference.md +- **Contributing files:** SKILL.md, references/labels.md, references/label-inference.md - **Status:** `extracted` ## context7-gitea-tea-cli diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md index ec604fd..ab3bbfe 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/pull-requests.md @@ -36,7 +36,7 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st `"get"`, `"get_diff"`, `"get_files"`, and `"get_status"` are covered here. `"get_reviews"`, `"get_review"`, and `"get_review_comments"` are covered in `references/reviews.md`. -- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments` as an integer count, not comment objects (see `references/reviews.md`). +- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments`, *when present*, as an integer count rather than comment objects — it was absent from a live `"get"` on a PR with no inline comments, so verify the key before reading it (see `references/reviews.md`). - `"get_diff"` returns raw diff text. - `"get_files"` returns the list of changed file objects. - `"get_status"` returns the combined commit status for the PR's head commit — CI result only, not review/approval state (see `references/merging.md`). diff --git a/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md b/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md index 77003b2..019c6e3 100644 --- a/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md +++ b/plugins/gitea/.apm/skills/gitea-prs/references/reviews.md @@ -49,6 +49,6 @@ Get the `comment_id` from `pull_request_read method: "get_review_comments"`. Cal - `method: "get_review"` (requires `review_id` — omitting it fails with `review_id is required`) — single review detail. - `method: "get_review_comments"` (`review_id` **optional** — omit it to list every inline comment on the PR in one call, rather than one review's) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. -**`review_comments` on the `"get"` response is a count, not the comments.** The full PR object returned by `pull_request_read method: "get"` carries `review_comments` as an integer — the number of inline review comments. It is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. +**`review_comments` on the `"get"` response is a count, not the comments — and it may be absent.** Where the full PR object returned by `pull_request_read method: "get"` carries `review_comments`, it is an integer: the number of inline review comments. Presence is not guaranteed. A live `"get"` against a PR with zero inline comments carried no such key at all — only `comments`, which counts issue-style comments, not review ones. Treat it as present only when non-zero, and check for the key before reading it rather than assuming the response shape. Either way it is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. **Inline-comment field names differ between write and read.** The `comments` array on `pull_request_review_write method: "create"` uses `old_line_num`/`new_line_num`. The `get_review_comments` read response uses different field names for the same concept — `position` (new-side line) and `old_position` (old-side line). Do not assume the same key names apply on both sides of the round trip. diff --git a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md index 176d427..b0d64f6 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/SKILL.md @@ -2,11 +2,11 @@ name: gitea-workflow description: > - Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that - could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which - domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an - unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component -> - `git-workflow`. + Use when a human wants an ambiguous Gitea status check — a no-args check-in, a bare number + asked *about* without saying issue or PR ("status of #42"), or a capability whose owning skill + is unclear. Not an agent caller -> `gitea-orchestrate`. Not a stated action on a number + ("close #42") -> `gitea-issues`. Not an unambiguous PR request -> `gitea-prs`. Not local-only + git -> `git-workflow`. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. @@ -31,9 +31,11 @@ The invocation's shape selects exactly one branch. | Invocation shape | Flow | Reference | |---|---|---| | No arguments, no specific request | Repo status check-in | `references/status-checkin.md` | -| A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | +| A bare number the user asks *about*, with neither "issue" nor "PR" said and no action stated | Resolve which domain the number belongs to | `references/number-resolution.md` | | A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | +A bare number carrying a stated action ("close #42", "merge #42", "label #42") is not row 2: that is a write, and row 2 only presents detail. Resolve the domain per `references/number-resolution.md`, then hand the action to `gitea-issues` or `gitea-prs` to perform. + Read only the reference file matching the selected branch — each is self-contained for its concern. ## Report diff --git a/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md b/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md index 280d567..357514b 100644 --- a/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md +++ b/plugins/gitea/.apm/skills/gitea-workflow/references/number-resolution.md @@ -16,4 +16,6 @@ The user has referenced a bare number without saying "issue" or "PR" (e.g. "what - `false` or absent → it's an issue. Present the issue detail already retrieved. 3. If the resolution call 404s, don't conclude the number doesn't exist. Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas), so report the 404 and suggest verifying the token carries `write:issue` rather than reporting "no such issue or PR." +If the user stated an action on the number rather than asking about it, resolution is only step one: hand the action, with the resolved domain, to `gitea-issues` or `gitea-prs` to carry out. Presenting detail is not a substitute for performing the write. + Then report per `SKILL.md`'s Report section, saying which domain the number turned out to be before showing detail ("That's a pull request:" / "That's an issue:") — otherwise the user cannot tell the resolution happened. diff --git a/plugins/gitea/agents/gitea-orchestrate.agent.md b/plugins/gitea/agents/gitea-orchestrate.agent.md index 45f93ce..50e3493 100644 --- a/plugins/gitea/agents/gitea-orchestrate.agent.md +++ b/plugins/gitea/agents/gitea-orchestrate.agent.md @@ -23,6 +23,7 @@ You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) an These are non-negotiable regardless of `confirm` or any skill-local override: - Never delete the repository's default branch (typically `main` or `master`) — refused outright, independent of `confirm`. - `delete_release` takes a numeric `id`; `delete_tag` takes a `tag_name` string. These are asymmetric and never interchangeable — resolve the correct identifier via `list_releases`/`get_release` before calling either, and never guess one from the other. +- `rename-branch` is gated like a delete even though it destroys nothing: what a rename does to open PRs using the branch as head or base, to a matching protection rule, and to every other clone's tracking branch is unconfirmed by `gitea-branches`' sources. Require `confirm: true`, and verify the PR and protection sides afterwards. - Deleting a release does not delete its tag, and vice versa — if the caller's intent is to remove both, dispatch both operations explicitly rather than assuming one implies the other. - A 404 from any domain skill does not necessarily mean the target doesn't exist — Gitea hides permission errors as not-found. Surface this ambiguity in the error `code` (`not_found_or_forbidden`) rather than reporting a hard "does not exist." - Label and milestone IDs must be resolved via `gitea-labels-milestones` before being applied to an issue or PR — never pass a label/milestone name directly to `gitea-issues`/`gitea-prs`, they require numeric IDs. @@ -43,7 +44,7 @@ Sub-skills carry their own local copies of relevant gotchas for humans who invok When invoked, you: 1. Parse the incoming workflow request (operation type, parameters, context overrides) -2. Check safety gates: if the operation is destructive (delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) and the request lacks explicit `confirm: true`, fail immediately with "requires explicit confirmation"; deleting the default branch is refused outright regardless of `confirm` +2. Check safety gates: if the operation is destructive (rename-branch, delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) and the request lacks explicit `confirm: true`, fail immediately with "requires explicit confirmation"; deleting the default branch is refused outright regardless of `confirm` 3. Route to the appropriate domain skill: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases` 4. Manage session context: resolve and carry forward `owner`/`repo` and any cached number-space resolutions, passing them explicitly to each skill 5. Handle error recovery: for recoverable failures (rate limiting, transient 5xx, pagination gaps) retry or complete the operation; for ambiguous 404s, attempt the permission-vs-not-found disambiguation before failing @@ -55,12 +56,12 @@ When invoked, you: - issues: list-issues, get-issue, create-issue, update-issue, comment-issue, search-issues - labels/milestones: list-labels, create-label, update-label, delete-label, list-milestones, create-milestone, update-milestone, close-milestone, delete-milestone, resolve-labels - prs: list-prs, get-pr, create-pr, update-pr, close-pr, reopen-pr, merge-pr, review-pr - - branches/commits: list-branches, create-branch, delete-branch, list-commits, get-commit + - branches/commits: list-branches, create-branch, rename-branch, delete-branch, list-commits, get-commit - files: get-file, get-dir, get-tree, write-file, delete-file - releases/tags: list-releases, get-release, create-release, delete-release, list-tags, create-tag, delete-tag - **parameters:** object, operation-specific arguments (issue/PR number, title, body, label names, tag name, file path, etc.) - **context:** object (optional), session state to carry forward (`owner`, `repo`, cached number-space resolutions) -- **confirm:** boolean (optional), explicit confirmation for destructive operations (required if not set for delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) +- **confirm:** boolean (optional), explicit confirmation for destructive operations (required if not set for rename-branch, delete-branch, delete-release, delete-tag, delete-label, delete-milestone, delete-file, merge-pr) ## Process diff --git a/plugins/gitea/skills/gitea-branches/SKILL.md b/plugins/gitea/skills/gitea-branches/SKILL.md index 4645153..a0a3a0d 100644 --- a/plugins/gitea/skills/gitea-branches/SKILL.md +++ b/plugins/gitea/skills/gitea-branches/SKILL.md @@ -3,11 +3,12 @@ name: gitea-branches description: > Use when listing, creating, renaming, or deleting branches in a Gitea repository, - or reading its commit history — even when the user does not say "Gitea". Not a + or reading its commit history — "what commits are on this branch", "what changed + in that commit" — even when the user does not say "Gitea". Not a local checkout's branches -> `git-branches`. Not local history -> `git-history`. Not a PR's head or base -> `gitea-prs`. -compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance. +compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate rename_branch, list_commits, and get_commit. Requires git remote "origin" pointing to the Gitea instance. metadata: category: integration diff --git a/plugins/gitea/skills/gitea-files/SKILL.md b/plugins/gitea/skills/gitea-files/SKILL.md index 401952a..168e875 100644 --- a/plugins/gitea/skills/gitea-files/SKILL.md +++ b/plugins/gitea/skills/gitea-files/SKILL.md @@ -23,8 +23,8 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g ## Gotchas - **A 404 may mean an under-scoped token, not a missing path.** Every tool here gates on `write:repository`, and Gitea masks insufficient scope as 404. Check scopes first. -- **Reads take `ref`, writes take `branch_name`.** One concept, two parameter names — chaining a read into a write drops the branch if you carry the wrong key. -- **`content` is base64 both ways.** Encode before a write, decode after a read. +- **Reads take `ref` (`tree_sha` on `get_repository_tree`), writes take `branch_name`.** One concept, three names — carry the wrong key and the branch is dropped. +- **`content` is base64 both ways — except under `withLines: true`.** Encode before a write, decode after a read; but with `withLines: true` `content` is already plain JSON text and the reported `"encoding": "base64"` is a lie. Decoding it yields garbage. ## Inputs diff --git a/plugins/gitea/skills/gitea-files/references/reading.md b/plugins/gitea/skills/gitea-files/references/reading.md index ca1847a..db2aa15 100644 --- a/plugins/gitea/skills/gitea-files/references/reading.md +++ b/plugins/gitea/skills/gitea-files/references/reading.md @@ -14,9 +14,13 @@ All three read calls select what to read with `ref` — a branch name, tag, or c `get_file_contents(owner, repo, ref, path)`. The response carries the file's `sha` at the **top level**, not nested under `content`. That field -is the write-ready SHA, so capture it whenever a write may follow. Content comes back -base64-encoded — decode it. Pass `withLines: true` only when you need numbered lines to quote -specific lines back to the user; omit it for a normal content fetch. +is the write-ready SHA, so capture it whenever a write may follow. + +Content comes back base64-encoded — decode it — **unless `withLines: true` was passed**, in which +case `content` is already plain text: a JSON array of `{"line": N, "content": "..."}` objects. +The response reports `"encoding": "base64"` either way, so that field is wrong under `withLines` +and decoding on its word yields garbage. Pass `withLines: true` only when you need numbered lines +to quote specific lines back to the user; omit it for a normal content fetch. ## One directory level diff --git a/plugins/gitea/skills/gitea-issues/SKILL.md b/plugins/gitea/skills/gitea-issues/SKILL.md index 72754b5..a210459 100644 --- a/plugins/gitea/skills/gitea-issues/SKILL.md +++ b/plugins/gitea/skills/gitea-issues/SKILL.md @@ -3,8 +3,8 @@ name: gitea-issues description: > 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`. + "close issue #N", "comment on issue #N", "label 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`. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token @@ -26,7 +26,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i ## Gotchas -- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one repo number space; pass `type: "issues"` to exclude PRs (or `"pulls"` for only PRs). Nothing on a list item flags which is which — `is_pull` appears only on `issue_read method: "get"`. +- **`list_issues` mixes in PRs unless you filter.** Issues and PRs share one number space; pass `type: "issues"` to exclude PRs (or `"pulls"`). `is_pull` is returned only by `issue_read method: "get"` — on a list item the only tell is `html_url`'s path segment (`/issues/` vs `/pulls/`). - **Label IDs and names are not interchangeable.** `issue_write` takes numeric IDs only; `list_issues` and `search_issues` filter by name; `issue_read "get"` returns names but `"get_labels"` returns full objects with IDs. Resolve via `gitea-labels-milestones` unless the caller named exact labels. - **A merge does not itself close the issue.** Gitea has no close-on-merge event, but a `Fixes #N` in the merged commits can, depending on merge style (`gitea-prs`). Re-read its state after a merge before closing it manually. - **A 404 may really be a 403.** Gitea hides permission errors as not-found — check the token's `write:issue` scope before concluding the issue does not exist. diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md index 8188386..172cea2 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/labels.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/labels.md @@ -3,6 +3,7 @@ topic: labels source_keys: - gitea-mcp-repo - gitea-mcp-slim-go + - context7-websites-gitea --- # Label operations diff --git a/plugins/gitea/skills/gitea-labels-milestones/references/sources.md b/plugins/gitea/skills/gitea-labels-milestones/references/sources.md index 0315aac..22db893 100644 --- a/plugins/gitea/skills/gitea-labels-milestones/references/sources.md +++ b/plugins/gitea/skills/gitea-labels-milestones/references/sources.md @@ -21,7 +21,7 @@ - **URL:** context7:/websites/gitea - **Description:** Official Gitea docs mirror on Context7 (docs.gitea.com content) — scoped/exclusive label conventions and milestone/label state-transition semantics - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md -- **Contributing files:** SKILL.md, references/label-inference.md +- **Contributing files:** SKILL.md, references/labels.md, references/label-inference.md - **Status:** `extracted` ## context7-gitea-tea-cli diff --git a/plugins/gitea/skills/gitea-prs/references/pull-requests.md b/plugins/gitea/skills/gitea-prs/references/pull-requests.md index ec604fd..ab3bbfe 100644 --- a/plugins/gitea/skills/gitea-prs/references/pull-requests.md +++ b/plugins/gitea/skills/gitea-prs/references/pull-requests.md @@ -36,7 +36,7 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st `"get"`, `"get_diff"`, `"get_files"`, and `"get_status"` are covered here. `"get_reviews"`, `"get_review"`, and `"get_review_comments"` are covered in `references/reviews.md`. -- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments` as an integer count, not comment objects (see `references/reviews.md`). +- `"get"` returns the full PR object: state, draft, merged, mergeable flags; `head`/`base` as full objects (`{ref, sha, repo?}`); `milestone` as a bare title string (not `{id, title}`); and `review_comments`, *when present*, as an integer count rather than comment objects — it was absent from a live `"get"` on a PR with no inline comments, so verify the key before reading it (see `references/reviews.md`). - `"get_diff"` returns raw diff text. - `"get_files"` returns the list of changed file objects. - `"get_status"` returns the combined commit status for the PR's head commit — CI result only, not review/approval state (see `references/merging.md`). diff --git a/plugins/gitea/skills/gitea-prs/references/reviews.md b/plugins/gitea/skills/gitea-prs/references/reviews.md index 77003b2..019c6e3 100644 --- a/plugins/gitea/skills/gitea-prs/references/reviews.md +++ b/plugins/gitea/skills/gitea-prs/references/reviews.md @@ -49,6 +49,6 @@ Get the `comment_id` from `pull_request_read method: "get_review_comments"`. Cal - `method: "get_review"` (requires `review_id` — omitting it fails with `review_id is required`) — single review detail. - `method: "get_review_comments"` (`review_id` **optional** — omit it to list every inline comment on the PR in one call, rather than one review's) — array of inline comments: `id`, `body`, `path`, `position`, `old_position`, `diff_hunk`, `user`, `html_url`, `created_at`, `updated_at`. -**`review_comments` on the `"get"` response is a count, not the comments.** The full PR object returned by `pull_request_read method: "get"` carries `review_comments` as an integer — the number of inline review comments. It is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. +**`review_comments` on the `"get"` response is a count, not the comments — and it may be absent.** Where the full PR object returned by `pull_request_read method: "get"` carries `review_comments`, it is an integer: the number of inline review comments. Presence is not guaranteed. A live `"get"` against a PR with zero inline comments carried no such key at all — only `comments`, which counts issue-style comments, not review ones. Treat it as present only when non-zero, and check for the key before reading it rather than assuming the response shape. Either way it is distinct from the `get_review_comments` method above, which returns the actual comment objects; reading the count is no substitute for that call. Older gitea-mcp releases misspelled this key as `review_scomments`; the misspelling was corrected upstream and the deployed v1.7.0 response carries no such key, so treat any instruction that reaches for `review_scomments` as stale. **Inline-comment field names differ between write and read.** The `comments` array on `pull_request_review_write method: "create"` uses `old_line_num`/`new_line_num`. The `get_review_comments` read response uses different field names for the same concept — `position` (new-side line) and `old_position` (old-side line). Do not assume the same key names apply on both sides of the round trip. diff --git a/plugins/gitea/skills/gitea-workflow/SKILL.md b/plugins/gitea/skills/gitea-workflow/SKILL.md index 176d427..b0d64f6 100644 --- a/plugins/gitea/skills/gitea-workflow/SKILL.md +++ b/plugins/gitea/skills/gitea-workflow/SKILL.md @@ -2,11 +2,11 @@ name: gitea-workflow description: > - Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that - could be an issue or a PR, or a capability whose owning skill is unclear. Resolves which - domain skill applies. Not an unambiguous issue request -> `gitea-issues`. Not an - unambiguous PR request -> `gitea-prs`. Not local git work with no Gitea component -> - `git-workflow`. + Use when a human wants an ambiguous Gitea status check — a no-args check-in, a bare number + asked *about* without saying issue or PR ("status of #42"), or a capability whose owning skill + is unclear. Not an agent caller -> `gitea-orchestrate`. Not a stated action on a number + ("close #42") -> `gitea-issues`. Not an unambiguous PR request -> `gitea-prs`. Not local-only + git -> `git-workflow`. compatibility: Requires Gitea MCP server configured with a token; delegates all calls to the six domain skills, which in turn require write:issue and write:repository scopes at minimum. @@ -31,9 +31,11 @@ The invocation's shape selects exactly one branch. | Invocation shape | Flow | Reference | |---|---|---| | No arguments, no specific request | Repo status check-in | `references/status-checkin.md` | -| A bare number, with neither "issue" nor "PR" said | Resolve which domain the number belongs to | `references/number-resolution.md` | +| A bare number the user asks *about*, with neither "issue" nor "PR" said and no action stated | Resolve which domain the number belongs to | `references/number-resolution.md` | | A named capability whose owning skill is unclear | Route to the domain skill that owns it | `references/skill-index.md` | +A bare number carrying a stated action ("close #42", "merge #42", "label #42") is not row 2: that is a write, and row 2 only presents detail. Resolve the domain per `references/number-resolution.md`, then hand the action to `gitea-issues` or `gitea-prs` to perform. + Read only the reference file matching the selected branch — each is self-contained for its concern. ## Report diff --git a/plugins/gitea/skills/gitea-workflow/references/number-resolution.md b/plugins/gitea/skills/gitea-workflow/references/number-resolution.md index 280d567..357514b 100644 --- a/plugins/gitea/skills/gitea-workflow/references/number-resolution.md +++ b/plugins/gitea/skills/gitea-workflow/references/number-resolution.md @@ -16,4 +16,6 @@ The user has referenced a bare number without saying "issue" or "PR" (e.g. "what - `false` or absent → it's an issue. Present the issue detail already retrieved. 3. If the resolution call 404s, don't conclude the number doesn't exist. Gitea hides permission errors as not-found (documented in `gitea-issues`' Gotchas), so report the 404 and suggest verifying the token carries `write:issue` rather than reporting "no such issue or PR." +If the user stated an action on the number rather than asking about it, resolution is only step one: hand the action, with the resolved domain, to `gitea-issues` or `gitea-prs` to carry out. Presenting detail is not a substitute for performing the write. + Then report per `SKILL.md`'s Report section, saying which domain the number turned out to be before showing detail ("That's a pull request:" / "That's an issue:") — otherwise the user cannot tell the resolution happened. -- 2.43.0 From 40ff89eabf6b9624aff90ea03dd8a675d0081300 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:39:22 +0000 Subject: [PATCH 85/89] fix(bin): restore prototype's deleted anti-patterns and two routing triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0020's stated anti-goal is satisfying the size gate by deleting content rather than relocating it. prototype's logic.md lost three anti-patterns, including "Don't generalise" — the one with a distinct failure mode, a throwaway growing abstractions for hypothetical futures, and the one the logic branch is most exposed to. It survived nowhere in the repo. The deletion bought nothing measurable: references/ sits outside the body FAIL, outside the 600-word suggestion and outside the Vale gate, and prototype's body is 483 words. 4011d14 restored the byte-identical defect in the sibling ui.md with exactly that reasoning in its message and left this file alone. Restored verbatim from main. improve-codebase-architecture had dropped "refactoring" from its description entirely, so "find refactoring opportunities in this repo" had no lexical match, while spending characters on a boundary against tdd — which cannot plausibly steal an architecture request. retrofit.md names that exact failure: an invented boundary costs characters and buys no routing accuracy. write-docs had dropped all four literal trigger phrasings, leaving them only in the body and a `when:` field, neither visible to the router at routing time. Its boundary also sent PRDs to grill-with-docs, which has no PRD flow, and the body repeated that at two more places. Per #123 nothing in the corpus produces a PRD, so no target was invented — the boundary is now honest about the ADR case only. Two READMEs added by this branch contradicted the SKILL.md they document: triage's label resolution, and grill-with-docs' fifth during-session behaviour. Unconditional reference pointers in tdd and improve-codebase-architecture are now conditional; the files stay at the skill root, which is #122's scope. Refs: #114, #122, #123 ADR: 0020 --- .../bin/.apm/skills/grill-with-docs/README.md | 5 +++-- .../improve-codebase-architecture/SKILL.md | 19 +++++++++++-------- .../.apm/skills/prototype/references/logic.md | 3 +++ plugins/bin/.apm/skills/tdd/SKILL.md | 2 +- plugins/bin/.apm/skills/triage/README.md | 2 +- plugins/bin/.apm/skills/write-docs/SKILL.md | 11 +++++++---- plugins/bin/skills/grill-with-docs/README.md | 5 +++-- .../improve-codebase-architecture/SKILL.md | 19 +++++++++++-------- .../bin/skills/prototype/references/logic.md | 3 +++ plugins/bin/skills/tdd/SKILL.md | 2 +- plugins/bin/skills/triage/README.md | 2 +- plugins/bin/skills/write-docs/SKILL.md | 11 +++++++---- 12 files changed, 52 insertions(+), 32 deletions(-) diff --git a/plugins/bin/.apm/skills/grill-with-docs/README.md b/plugins/bin/.apm/skills/grill-with-docs/README.md index 11b544b..1ea62ce 100644 --- a/plugins/bin/.apm/skills/grill-with-docs/README.md +++ b/plugins/bin/.apm/skills/grill-with-docs/README.md @@ -4,10 +4,11 @@ The grilling interview, run against the project's domain model — and writing d ## What it does -Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it four ways: +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it five ways: - **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. - **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Stress-tests domain relationships with concrete scenarios**, inventing edge cases that force the user to be precise about where one concept ends and the next begins. - **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. - **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. @@ -31,6 +32,6 @@ Describe the plan or design. Expect questions one at a time, each with a recomme | File | Purpose | |------|---------| -| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the four during-session behaviours, and the three-part ADR test | +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the five during-session behaviours, and the three-part ADR test | | `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | | `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md index 6ec346b..750876a 100644 --- a/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/.apm/skills/improve-codebase-architecture/SKILL.md @@ -1,10 +1,11 @@ --- name: improve-codebase-architecture description: > - Use when the user wants a codebase's architecture improved — deepening - opportunities that turn shallow modules into deep ones, informed by - `CONTEXT.md` and `docs/adr/`. Not debugging a failure -> `diagnose`. Not - test-first feature work -> `tdd`. + Use when the user wants to improve architecture, find refactoring + opportunities, consolidate tightly-coupled modules, or make a codebase more + testable and AI-navigable — deepening opportunities that turn shallow modules + into deep ones, informed by `CONTEXT.md` and `docs/adr/`. Not debugging a + failure -> `diagnose`. --- # Improve Codebase Architecture @@ -13,7 +14,7 @@ Surface architectural friction and propose **deepening opportunities** — refac ## Glossary -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." - **Module** — anything with an interface and an implementation (function, class, package, slice). - **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. @@ -24,12 +25,14 @@ Use these terms exactly in every suggestion. Consistent language is the point - **Leverage** — what callers get from depth. - **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): +Key principles: - **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. - **The interface is the test surface.** - **One adapter = hypothetical seam. Two adapters = real seam.** +If a term or principle above is ambiguous in the case in front of you, or you need the definitions and the principles the two lists leave out, read `LANGUAGE.md`. + This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. ## Process @@ -57,7 +60,7 @@ Present a numbered list of deepening opportunities. For each candidate: - **Solution** — plain English description of what would change - **Benefits** — explained in terms of locality and leverage, and also in how tests would improve -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." +**Use CONTEXT.md vocabulary for the domain, and the architecture glossary above for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." **ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. @@ -72,4 +75,4 @@ Side effects happen inline as decisions crystallize: - **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. - **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). +- **Want to explore alternative interfaces for the deepened module?** Read `INTERFACE-DESIGN.md`. diff --git a/plugins/bin/.apm/skills/prototype/references/logic.md b/plugins/bin/.apm/skills/prototype/references/logic.md index 2945853..2376fd1 100644 --- a/plugins/bin/.apm/skills/prototype/references/logic.md +++ b/plugins/bin/.apm/skills/prototype/references/logic.md @@ -72,5 +72,8 @@ When the prototype has done its job, the answer to the question is the only thin ## Anti-patterns +- **Don't add tests.** A prototype that needs tests is no longer a prototype. +- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. +- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. - **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. - **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/plugins/bin/.apm/skills/tdd/SKILL.md b/plugins/bin/.apm/skills/tdd/SKILL.md index d4a97f6..ad3be57 100644 --- a/plugins/bin/.apm/skills/tdd/SKILL.md +++ b/plugins/bin/.apm/skills/tdd/SKILL.md @@ -16,7 +16,7 @@ description: > **Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. -See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. +If you need worked examples of the difference — a behaviour-level test beside the implementation-coupled version of the same check — read `tests.md`. If a test needs a collaborator faked, read `mocking.md` before reaching for a mock. ## Anti-Pattern: Horizontal Slices diff --git a/plugins/bin/.apm/skills/triage/README.md b/plugins/bin/.apm/skills/triage/README.md index 3f21e51..d9f5da2 100644 --- a/plugins/bin/.apm/skills/triage/README.md +++ b/plugins/bin/.apm/skills/triage/README.md @@ -12,7 +12,7 @@ A run does one of three things depending on what the maintainer asks for: - **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. - **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. -Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — the mapping has to be supplied to the run. +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — each is resolved against the tracker's live label set before it is applied, and a name with no counterpart there is reported to the maintainer as a gap rather than guessed at. ## Composition diff --git a/plugins/bin/.apm/skills/write-docs/SKILL.md b/plugins/bin/.apm/skills/write-docs/SKILL.md index 309ec6c..30ea51d 100644 --- a/plugins/bin/.apm/skills/write-docs/SKILL.md +++ b/plugins/bin/.apm/skills/write-docs/SKILL.md @@ -2,8 +2,10 @@ name: write-docs description: > Use when the user wants technical documentation produced or updated from code - or spec, every claim traced to a source. Not a PRD, ADR, or decision doc -> - `grill-with-docs`. Not an external tool researched from its docs -> `research`. + or spec, every claim traced to a source — "write docs for X", "document this + module", "create docs for this feature", "write a README for this". Not an ADR + or other decision record -> `grill-with-docs`. Not an external tool researched + from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec @@ -38,7 +40,8 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `grill-me` or `grill-with-docs` +- User wants an ADR, decision doc, or architecture proposal → `grill-with-docs`, which writes ADRs +- User wants a PRD → no skill in this set produces one; say so rather than redirecting - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -91,7 +94,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `grill-me` or `grill-with-docs` +- Requested output is an ADR, decision doc, or architecture proposal → redirect to `grill-with-docs`; for a PRD, say no skill here produces one instead of redirecting ## Self-check diff --git a/plugins/bin/skills/grill-with-docs/README.md b/plugins/bin/skills/grill-with-docs/README.md index 11b544b..1ea62ce 100644 --- a/plugins/bin/skills/grill-with-docs/README.md +++ b/plugins/bin/skills/grill-with-docs/README.md @@ -4,10 +4,11 @@ The grilling interview, run against the project's domain model — and writing d ## What it does -Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it four ways: +Runs the same relentless one-question-at-a-time interview as `grill-me`, with the project's own documentation as an active participant. During codebase exploration it also locates the domain documentation — a root `CONTEXT.md` and `docs/adr/`, or a `CONTEXT-MAP.md` pointing at per-context glossaries and ADR directories in a multi-context repo — and then uses it five ways: - **Challenges terms against the glossary.** When the user's usage conflicts with what `CONTEXT.md` already defines, that is raised immediately rather than absorbed. - **Sharpens fuzzy language** by proposing a precise canonical term ("you're saying 'account' — do you mean the Customer or the User?"). +- **Stress-tests domain relationships with concrete scenarios**, inventing edge cases that force the user to be precise about where one concept ends and the next begins. - **Cross-references claims against the code**, and surfaces contradictions between what the user says happens and what the code does. - **Updates `CONTEXT.md` inline**, the moment a term is resolved, rather than batching changes to the end of the session where they get lost. @@ -31,6 +32,6 @@ Describe the plan or design. Expect questions one at a time, each with a recomme | File | Purpose | |------|---------| -| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the four during-session behaviours, and the three-part ADR test | +| `SKILL.md` | The interview instruction plus the domain-awareness rules: file layout discovery, the five during-session behaviours, and the three-part ADR test | | `CONTEXT-FORMAT.md` | Skill-root document, cited when a term is resolved: the structure of a `CONTEXT.md` and how to write a Language entry | | `ADR-FORMAT.md` | Skill-root document, cited when an ADR is offered: `docs/adr/` naming, sequential numbering, and the ADR template | diff --git a/plugins/bin/skills/improve-codebase-architecture/SKILL.md b/plugins/bin/skills/improve-codebase-architecture/SKILL.md index 6ec346b..750876a 100644 --- a/plugins/bin/skills/improve-codebase-architecture/SKILL.md +++ b/plugins/bin/skills/improve-codebase-architecture/SKILL.md @@ -1,10 +1,11 @@ --- name: improve-codebase-architecture description: > - Use when the user wants a codebase's architecture improved — deepening - opportunities that turn shallow modules into deep ones, informed by - `CONTEXT.md` and `docs/adr/`. Not debugging a failure -> `diagnose`. Not - test-first feature work -> `tdd`. + Use when the user wants to improve architecture, find refactoring + opportunities, consolidate tightly-coupled modules, or make a codebase more + testable and AI-navigable — deepening opportunities that turn shallow modules + into deep ones, informed by `CONTEXT.md` and `docs/adr/`. Not debugging a + failure -> `diagnose`. --- # Improve Codebase Architecture @@ -13,7 +14,7 @@ Surface architectural friction and propose **deepening opportunities** — refac ## Glossary -Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." - **Module** — anything with an interface and an implementation (function, class, package, slice). - **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. @@ -24,12 +25,14 @@ Use these terms exactly in every suggestion. Consistent language is the point - **Leverage** — what callers get from depth. - **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. -Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): +Key principles: - **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. - **The interface is the test surface.** - **One adapter = hypothetical seam. Two adapters = real seam.** +If a term or principle above is ambiguous in the case in front of you, or you need the definitions and the principles the two lists leave out, read `LANGUAGE.md`. + This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. ## Process @@ -57,7 +60,7 @@ Present a numbered list of deepening opportunities. For each candidate: - **Solution** — plain English description of what would change - **Benefits** — explained in terms of locality and leverage, and also in how tests would improve -**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." +**Use CONTEXT.md vocabulary for the domain, and the architecture glossary above for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." **ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. @@ -72,4 +75,4 @@ Side effects happen inline as decisions crystallize: - **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `grill-with-docs`, in the format `grill-with-docs`'s `CONTEXT-FORMAT.md` defines. Create the file lazily if it doesn't exist. - **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. - **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See `grill-with-docs`'s `ADR-FORMAT.md`. -- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). +- **Want to explore alternative interfaces for the deepened module?** Read `INTERFACE-DESIGN.md`. diff --git a/plugins/bin/skills/prototype/references/logic.md b/plugins/bin/skills/prototype/references/logic.md index 2945853..2376fd1 100644 --- a/plugins/bin/skills/prototype/references/logic.md +++ b/plugins/bin/skills/prototype/references/logic.md @@ -72,5 +72,8 @@ When the prototype has done its job, the answer to the question is the only thin ## Anti-patterns +- **Don't add tests.** A prototype that needs tests is no longer a prototype. +- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. +- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. - **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. - **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/plugins/bin/skills/tdd/SKILL.md b/plugins/bin/skills/tdd/SKILL.md index d4a97f6..ad3be57 100644 --- a/plugins/bin/skills/tdd/SKILL.md +++ b/plugins/bin/skills/tdd/SKILL.md @@ -16,7 +16,7 @@ description: > **Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior. -See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines. +If you need worked examples of the difference — a behaviour-level test beside the implementation-coupled version of the same check — read `tests.md`. If a test needs a collaborator faked, read `mocking.md` before reaching for a mock. ## Anti-Pattern: Horizontal Slices diff --git a/plugins/bin/skills/triage/README.md b/plugins/bin/skills/triage/README.md index 3f21e51..d9f5da2 100644 --- a/plugins/bin/skills/triage/README.md +++ b/plugins/bin/skills/triage/README.md @@ -12,7 +12,7 @@ A run does one of three things depending on what the maintainer asks for: - **Triage a specific issue** — gather context (including prior triage notes, so resolved questions are not re-asked, and `.out-of-scope/` records that resemble the issue), recommend a category and state with reasoning, attempt reproduction for bugs *before* any grilling, run a `grill-with-docs` session if the issue needs fleshing out, then apply the outcome. - **Quick state override** — "move #42 to ready-for-agent" is trusted and applied directly, skipping grilling, after confirming the exact changes. -Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — the mapping has to be supplied to the run. +Two hard rules: every comment or issue the skill posts during triage must open with the AI-generated disclaimer, and the canonical role names above are *not* necessarily the label strings in the tracker — each is resolved against the tracker's live label set before it is applied, and a name with no counterpart there is reported to the maintainer as a gap rather than guessed at. ## Composition diff --git a/plugins/bin/skills/write-docs/SKILL.md b/plugins/bin/skills/write-docs/SKILL.md index 309ec6c..30ea51d 100644 --- a/plugins/bin/skills/write-docs/SKILL.md +++ b/plugins/bin/skills/write-docs/SKILL.md @@ -2,8 +2,10 @@ name: write-docs description: > Use when the user wants technical documentation produced or updated from code - or spec, every claim traced to a source. Not a PRD, ADR, or decision doc -> - `grill-with-docs`. Not an external tool researched from its docs -> `research`. + or spec, every claim traced to a source — "write docs for X", "document this + module", "create docs for this feature", "write a README for this". Not an ADR + or other decision record -> `grill-with-docs`. Not an external tool researched + from its docs -> `research`. version: "1.0" updated: 2026-05-17 when: invoked by explicit trigger ("write docs for X", "document this module", "create docs for this feature") or implicit request to produce technical documentation from code or spec @@ -38,7 +40,8 @@ You are a technical writer that produces documentation by reading code and spec - User says "write docs for X", "document this", "create docs for this feature", "write a README for this" **Do not use when:** -- User wants a PRD, decision doc, or architecture proposal → `grill-me` or `grill-with-docs` +- User wants an ADR, decision doc, or architecture proposal → `grill-with-docs`, which writes ADRs +- User wants a PRD → no skill in this set produces one; say so rather than redirecting - User wants to document a skill file (skill files are self-describing) - User wants marketing or blog copy - Documentation requires tacit organisational knowledge that cannot be read from code or spec @@ -91,7 +94,7 @@ You are a technical writer that produces documentation by reading code and spec - Stage skipped without a logged reason → flag and require the one-sentence log before continuing - Code behaviour is undocumentable (internal implementation detail, no public spec) → note as out-of-scope in the doc; do not invent an explanation - Reader Testing sub-agent fails on multiple questions → surface the failures, return to step 4; do not mark complete -- Requested output is a PRD, decision doc, or architecture proposal → redirect to `grill-me` or `grill-with-docs` +- Requested output is an ADR, decision doc, or architecture proposal → redirect to `grill-with-docs`; for a PRD, say no skill here produces one instead of redirecting ## Self-check -- 2.43.0 From f40deada8610356dd8daecdfbeeef4252d0cb752 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:39:31 +0000 Subject: [PATCH 86/89] fix(lint): restore the glob-scoping rule and warn about the MDX invocation kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vale-config deleted "settings in a glob section only apply to files matching that glob" — the single most common Vale misconfiguration. Nothing in either lint skill told an agent that BasedOnStyles under `[*.md]` governs `.md` only; the nearest survivor was a parenthetical in a reference file the skill opens only for the full field listing. Restored to the always-loaded body, at the point of use. vale-config also never mentioned `[formats]` or MDX at all, leaving the whole decision in that same reference file. Verified against Vale 3.15.2: an unmapped `.mdx` covered by one of your globs is `E100 [lintMDX]`, exit 2, and every other file in that invocation produces no output whatsoever. Because the failure is invocation-wide rather than per-file, it belongs in the Gotchas. The crash requires a glob that actually covers `.mdx` — under `[*.md]` the file is skipped and nothing fails — so the Gotcha states that condition rather than the broader claim that any `.mdx` in the tree kills the run. The mapping-versus-mdx2vast attribution disagreed across the two skills: vale-run said `[formats] mdx = md` is "what vale-config recommends" while vale-config presented it as a bare either/or. Fixed on the source side by having vale-config actually recommend it and say why, which makes vale-run's existing sentence true with no edit to vale-run. Refs: #117 --- plugins/lint/.apm/skills/vale-config/SKILL.md | 3 +++ .../skills/vale-config/references/configuration-reference.md | 2 +- plugins/lint/skills/vale-config/SKILL.md | 3 +++ .../skills/vale-config/references/configuration-reference.md | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/lint/.apm/skills/vale-config/SKILL.md b/plugins/lint/.apm/skills/vale-config/SKILL.md index e29c541..7511181 100644 --- a/plugins/lint/.apm/skills/vale-config/SKILL.md +++ b/plugins/lint/.apm/skills/vale-config/SKILL.md @@ -20,6 +20,7 @@ metadata: - `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. - Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. - `.vale.ini` order is enforced, not stylistic: put core settings first, then `[formats]`, then glob sections. A core setting (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) written below a `[glob]` header is a hard error — `E201 ... 'StylesPath' is a core option; it should be defined above any syntax-specific options`, exit 2, nothing linted. `Packages` is the exception, and the worse one: below a glob header it is accepted with no error, then ignored — `vale sync` reports `Synced 0 package(s)` and downloads nothing. +- An `.mdx` file covered by one of your globs takes the whole run down unless `[formats]` maps it. Vale 3.15.2 has no built-in MDX support: unmapped, it shells out to an external `mdx2vast` binary, and with that absent from `PATH` the invocation dies on `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2 — every other file in the same command goes unlinted, with no output of its own. Default to the mapping — a `[formats]` section holding `mdx = md`, above the glob sections — which needs nothing installed; `npm install -g mdx2vast` is the alternative. The choice also inverts the inline-suppression syntax `vale-run` uses, so record which one the config took. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -35,6 +36,8 @@ metadata: BasedOnStyles = Vale ``` `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`): no `Packages` entry and no `vale sync`. It still needs the `StylesPath` directory to exist — declare `StylesPath = styles` without creating `styles/` and even a `Vale`-only config dies with `E201 ... The path '...' does not exist`, exit 2. That is why the previous step creates the directory. + + **Settings in a glob section only apply to files matching that glob.** `BasedOnStyles` under `[*.md]` governs `.md` and nothing else: a `.mdx`, `.rst` or `.txt` in the same tree has no style active, is skipped without being counted, and a run over only such files reports `0 files` and exits 0 — indistinguishable from clean. Give every extension you mean to lint a glob that covers it. - [ ] **Add third-party styles** (optional) by declaring them in `Packages`, then activating them in the same or another glob's `BasedOnStyles`: ```ini Packages = Google, write-good diff --git a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md index 5448ab6..b572d41 100644 --- a/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/.apm/skills/vale-config/references/configuration-reference.md @@ -25,7 +25,7 @@ Map an unrecognized extension onto a supported one so Vale lints it with the rig mdx = md ``` -`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Install it with `npm install -g mdx2vast` or take the mapping. +`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Take the mapping: it is this skill's recommended default, because it needs nothing installed, and it is the branch `vale-run` assumes when it documents inline suppressions. Install `mdx2vast` (`npm install -g mdx2vast`) only when something else in the toolchain already needs the native MDX parser. The choice also decides the inline-suppression syntax, and it is inverted between the two: mapped to `md`, `.mdx` takes Markdown's `<!-- vale off -->`; native, it takes `{/* vale off */}`. `vale-run`'s `references/troubleshooting.md` carries the verified matrix. diff --git a/plugins/lint/skills/vale-config/SKILL.md b/plugins/lint/skills/vale-config/SKILL.md index e29c541..7511181 100644 --- a/plugins/lint/skills/vale-config/SKILL.md +++ b/plugins/lint/skills/vale-config/SKILL.md @@ -20,6 +20,7 @@ metadata: - `vale sync` alone does not clear that `E100`. Sync fetches only what the top-level `Packages` key declares, so against a `BasedOnStyles`-only name it reports `Synced 0 package(s)` and exits 0, fetching nothing. Add the style to `Packages`, then sync. A style lints only once it is in both keys — and the reverse case is silent, exiting 0. - Only *package* styles need fetching: built-in `Vale`, and any style whose YAML is already committed under `StylesPath`, lint with no `Packages` entry and no sync. - `.vale.ini` order is enforced, not stylistic: put core settings first, then `[formats]`, then glob sections. A core setting (`StylesPath`, `MinAlertLevel`, `Vocab`, `IgnoredScopes`, `SkippedScopes`) written below a `[glob]` header is a hard error — `E201 ... 'StylesPath' is a core option; it should be defined above any syntax-specific options`, exit 2, nothing linted. `Packages` is the exception, and the worse one: below a glob header it is accepted with no error, then ignored — `vale sync` reports `Synced 0 package(s)` and downloads nothing. +- An `.mdx` file covered by one of your globs takes the whole run down unless `[formats]` maps it. Vale 3.15.2 has no built-in MDX support: unmapped, it shells out to an external `mdx2vast` binary, and with that absent from `PATH` the invocation dies on `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2 — every other file in the same command goes unlinted, with no output of its own. Default to the mapping — a `[formats]` section holding `mdx = md`, above the glob sections — which needs nothing installed; `npm install -g mdx2vast` is the alternative. The choice also inverts the inline-suppression syntax `vale-run` uses, so record which one the config took. - A rule scoped to `text.frontmatter.<key>` silently matches nothing when the field spans multiple lines in most YAML forms. If you scope a rule to frontmatter, read `references/configuration-reference.md` first. ## Setup workflow @@ -35,6 +36,8 @@ metadata: BasedOnStyles = Vale ``` `Vale` here is the built-in style (`Vale.Spelling`, `Vale.Terms`, `Vale.Avoid`, `Vale.Repetition`): no `Packages` entry and no `vale sync`. It still needs the `StylesPath` directory to exist — declare `StylesPath = styles` without creating `styles/` and even a `Vale`-only config dies with `E201 ... The path '...' does not exist`, exit 2. That is why the previous step creates the directory. + + **Settings in a glob section only apply to files matching that glob.** `BasedOnStyles` under `[*.md]` governs `.md` and nothing else: a `.mdx`, `.rst` or `.txt` in the same tree has no style active, is skipped without being counted, and a run over only such files reports `0 files` and exits 0 — indistinguishable from clean. Give every extension you mean to lint a glob that covers it. - [ ] **Add third-party styles** (optional) by declaring them in `Packages`, then activating them in the same or another glob's `BasedOnStyles`: ```ini Packages = Google, write-good diff --git a/plugins/lint/skills/vale-config/references/configuration-reference.md b/plugins/lint/skills/vale-config/references/configuration-reference.md index 5448ab6..b572d41 100644 --- a/plugins/lint/skills/vale-config/references/configuration-reference.md +++ b/plugins/lint/skills/vale-config/references/configuration-reference.md @@ -25,7 +25,7 @@ Map an unrecognized extension onto a supported one so Vale lints it with the rig mdx = md ``` -`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Install it with `npm install -g mdx2vast` or take the mapping. +`mdx` is the case that matters, because Vale 3.15.2 has no built-in MDX support. The mapping above is not cosmetic: it is what lets `.mdx` files lint with nothing else installed. Leave it out and Vale takes the native MDX path, which shells out to an external `mdx2vast` binary — absent from `PATH`, the run dies with `E100 [lintMDX] Runtime error / mdx2vast not found`, exit 2, and every other file in the same invocation goes unlinted too. Take the mapping: it is this skill's recommended default, because it needs nothing installed, and it is the branch `vale-run` assumes when it documents inline suppressions. Install `mdx2vast` (`npm install -g mdx2vast`) only when something else in the toolchain already needs the native MDX parser. The choice also decides the inline-suppression syntax, and it is inverted between the two: mapped to `md`, `.mdx` takes Markdown's `<!-- vale off -->`; native, it takes `{/* vale off */}`. `vale-run`'s `references/troubleshooting.md` carries the verified matrix. -- 2.43.0 From ccc54cbb5804166449204ccef5530c9738aa7d8e Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:39:42 +0000 Subject: [PATCH 87/89] docs: retire ADR-0020's stale measurements and fix a CONTEXT.md code span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 971e148 de-pinned three stale present-tense figures in gates.md and left ADR-0020's copy of each, while CONTEXT.md points readers at the ADR for the current number. All three were wrong at HEAD, re-measured against a `git archive` of the tree rather than the working copy: - "26 description FAILs, 9 body FAILs, 2 dangling targets, 58 SUGGESTIONs" is now 0 / 0 / 0 / 29. - "pre-commit run --all-files is red on 10 alerts" is 0 errors in 39 files. - apm-orchestrate was cited as a 900-word body FAIL. It is 876 words, a SUGGESTION. git-orchestrate at 933 and gitea-orchestrate at 1,199 were correct. Marked historical with a dated amendment and the measured current values, following the convention already in this file. The "realistic landing is somewhere in that 33-58% band" projection is left alone: it is a forecast rather than a measurement, and the realised 55.3% falls inside it. The Enforcement table claimed exhaustiveness while listing only ERROR and SUGGESTION for boundary targets; gates.md documents a third verdict, INFO "DID NOT RUN". Added. CONTEXT.md carried shell-escaped backticks inside a markdown code span, which closes the span early and leaves an unterminated double-backtick span that swallows the rest of the glossary entry — in the definition of a term this branch introduces. It also called the ADR's pre-retrofit 23,427 the current preload figure; the measured value is 10,478, recorded here so the glossary and the gate agree. ADR: 0020 --- CONTEXT.md | 10 ++++-- ...l-description-and-body-context-contract.md | 35 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index d9dd395..5e9754d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,8 +16,12 @@ decisions. **Preload tax**: The always-on context cost of every installed skill's `name` and `description`, charged from the -first token of every session whether the skill is invoked or not. Measurement method and current -figure: ADR-0020. +first token of every session whether the skill is invoked or not. Measurement method: ADR-0020. Its +**23,427 characters is the pre-retrofit baseline, not a current reading** — measured at the decision +commit, before #99. Across the same 39 skills it is **10,478 characters** (~2,620 tokens) as of +2026-09-01. Both figures move with the corpus, so re-derive rather than quote either: sum +`len(name) + len(description)` over the frontmatter of every `plugins/*/.apm/skills/*/SKILL.md`, +folding block scalars as `scripts/skill-size-check.sh` does. _Avoid_: context cost, token overhead **Skill context contract**: @@ -44,7 +48,7 @@ _Avoid_: router body, thin body A skill reached only by typing its slash command, declared `disable-model-invocation: true`. The host withholds it from the model-visible listing entirely, so it pays no preload tax and its description becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other skill can route to -a hand-invoked skill** — a `Call \`x\`` step in another skill's body stops working the moment `x` +a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops working the moment `x` takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`. _Avoid_: manual skill, disabled skill diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md index ef38558..09b7da2 100644 --- a/docs/adr/0020-skill-description-and-body-context-contract.md +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -127,8 +127,15 @@ clause**, and a **boundary clause**. Capability enumeration, output-format detai gate shipping hot with no baseline cannot give two answers. Under the walk-up those four resolve because sibling plugins are in the universe — no plugin here declares a cross-plugin apm dependency, and none needs to. Verified: a tree holding only `plugins/` and the root `apm.yml`, - with no `.claude/` or `.agents/` anywhere, now produces findings identical to the working tree — - 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 missing references, 58 SUGGESTIONs. + with no `.claude/` or `.agents/` anywhere, produced findings identical to the working tree. The + figures that reproduction recorded — 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 + missing references, 58 SUGGESTIONs — are the **pre-retrofit** corpus as it stood when the + experiment ran, kept here as the evidence for the install-independence claim, not as a current + reading. *Amended 2026-09-01: the #99 retrofit took the first three to zero. Measured at that + date over the same install-free tree: 0 description FAILs, 0 body FAILs, 0 dangling targets, 0 + missing references, 29 SUGGESTIONs.* What the experiment establishes is that the two trees agree, + not what either measured; re-derive rather than quote — + `bash scripts/skill-size-check.sh plugins/*/.apm/skills/*/SKILL.md`. - **The universe is the apm marketplace, and nothing else.** A routing target resolves to a skill or an agent, or it does not resolve. Host built-ins are deliberately outside it: `/compact`, `/clear` and `/init` are Claude Code slash commands with no counterpart in Copilot CLI or Codex, so a @@ -185,8 +192,11 @@ becomes the system prompt of a fresh context. The rationale for the 900-word FAI That exemption is expressed in `agent-audit/scripts/validate.sh`, which has no body constant, and in the `files:` pattern of the `skill-size-check` pre-commit hook, which is `SKILL.md`-only. It is *not* expressed in `scripts/skill-size-check.sh` itself, which measures whatever path it is handed — -running it directly over `plugins/*/.apm/agents/*.agent.md` today reports 900-word body FAILs on -`git-orchestrate` (933), `gitea-orchestrate` (1,199) and `apm-orchestrate` (1,080). Agents escape by +running it directly over `plugins/*/.apm/agents/*.agent.md` exits 1 with 900-word body FAILs on +`git-orchestrate` and `gitea-orchestrate`. *Amended 2026-09-01: this sentence named a third agent, +`apm-orchestrate`, at 1,080 words. It is 876 today — a SUGGESTION, not a FAIL. Counts are +deliberately no longer pinned here: agent bodies are edited like any other file and a figure in this +paragraph goes stale the moment one is trimmed. Run the command.* Agents escape by file pattern, not by the script knowing the difference. Anyone widening that pattern to cover agents would silently enforce a gate this ADR declines to set. @@ -245,7 +255,7 @@ which tier each rule is in, because the failure this ADR is most exposed to is a | description characters (250 SUGGESTION † / 400 FAIL) | skills, agents | deterministic | `scripts/skill-size-check.sh`; constants mirrored in `skill-audit/scripts/validate.sh` and `agent-audit/scripts/validate.sh` | | body-only words (600 SUGGESTION / 900 FAIL) | skills | deterministic | `skill-size-check.sh`, `skill-audit/scripts/validate.sh` | | description present and non-empty (ERROR) | skills, agents | deterministic | same | -| boundary target resolves to a real skill or agent (ERROR when written in route notation — `/name`, or any arrow form; or when a *terminal* bare name's own sentence names another target that resolves; SUGGESTION otherwise) | skills, agents | deterministic | same | +| boundary target resolves to a real skill or agent — **three** verdicts, not two (ERROR when written in route notation — `/name`, or any arrow form; or when a *terminal* bare name's own sentence names another target that resolves. SUGGESTION otherwise. INFO, "DID NOT RUN", exit 0, when no skill universe could be determined for the path at all — no authoring root above it, no apm package root, no declared apm dependencies, no deployed `.claude/` or `.agents/` tree: the targets are named and left unchecked) | skills, agents | deterministic | same | | boundary clause absent — `absent` (SUGGESTION) † | skills, agents | deterministic | same | | an arrow clause is present but no target can be read out of it — `unparsed` (SUGGESTION) † | skills, agents | deterministic | same | | one arrow clause naming two or more targets, of which only the first is resolved (SUGGESTION, issue #107) † | skills, agents | deterministic | same | @@ -378,10 +388,17 @@ carries is the ordinary one for hot gates: a gate expensive enough to be inconve with `SKIP=` and loses its authority. **A second hot gate ships alongside it, and it is easy to miss.** `Kyberforge.CompositionNote` is -`level: error` like every other rule in that style, so `pre-commit run --all-files` is red on 10 -alerts across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow` -independently of anything `skill-size-check` reports. Someone scoping the #99 retrofit off the size -findings alone will fix those and still be blocked. The two gates want fixing together. +`level: error` like every other rule in that style, so at decision time `pre-commit run --all-files` +was red on 10 alerts across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and +`gitea-workflow` independently of anything `skill-size-check` reports. Someone scoping the #99 +retrofit off the size findings alone would have fixed those and still been blocked. The two gates +wanted fixing together, and were. *Amended 2026-09-01: that figure is historical. The Vale prefilter +over the same 39 files now reports 0 errors, 0 warnings and 0 suggestions, so +`Kyberforge.CompositionNote` fires nowhere in the corpus today. The rule is still hot and still +independent of `skill-size-check`, so a new description can reintroduce it; `skill-size-check` does +not cover the Vale half, and no `references/` file is linted by anything (`docs/spec/gates.md` has +both causes, issue #117 tracks them). Re-derive rather than quote —* +`bash plugins/kyberforge/.apm/skills/skill-audit/scripts/vale-wrap.sh plugins/*/.apm/skills/*/SKILL.md`. **A ceiling does not produce an average.** If every author writes to the 400-character FAIL, the preload lands at 39 × 400 = 15,600 chars — a 33% cut off 23,427, not the ~50% intended. Writing to -- 2.43.0 From 0427422765b007f9fe05dbe45527e171fed76e24 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 12:39:54 +0000 Subject: [PATCH 88/89] chore(release): bump the six plugins and the catalog to patch, not minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch is 31 fix, 30 refactor, 11 docs, 4 chore, 2 test — zero feat, zero `!`, zero BREAKING CHANGE — and it adds no skill, agent, command or hook. Two rules shipped in this repo both make that a patch: forge's version-bump.md, which lands in this very branch ("minor for new capability, patch for a fix/refactor"), and git-commits' conventional-commits-spec.md, which maps fix to PATCH and refactor to none. Minor was the one answer neither rule produces, and the release was arguing with a policy it was simultaneously introducing. bin 1.1.6, core 1.1.2, git 1.3.6, gitea 1.3.7, kyberforge 1.6.1, lint 1.1.7 The catalog goes 0.5.0 to 0.4.6 for the same reason: apm-workflow's marketplace.md reserves a catalog minor for a packages[] entry added or removed and assigns patch to an existing entry's version moving. The set is 7 entries on both sides with unchanged names, so only the patch trigger applies. Each number is +1 patch on the pre-bump value rather than stacked on the minor, and executables.allow follows kyberforge to 1.6.1 so the ADR-0019 SessionStart hook does not orphan. Note what this does not settle: four published files were removed from the installed tree on this branch, three more moved, and caveman gained disable-model-invocation, which retires its old triggers. Under a strict reading of the repo's own breaking rule those are major-class and they currently ship under refactor: with no marker. Patch is right for the code; whether the deployed skill surface is a public contract is still unwritten, and that question outlives this commit. --- .claude-plugin/marketplace.json | 14 +++++++------- .github/plugin/marketplace.json | 14 +++++++------- apm.yml | 18 +++++++++--------- plugins/bin/.claude-plugin/plugin.json | 2 +- plugins/bin/.github/plugin/plugin.json | 2 +- plugins/bin/apm.yml | 2 +- plugins/core/.claude-plugin/plugin.json | 2 +- plugins/core/.github/plugin/plugin.json | 2 +- plugins/core/apm.yml | 2 +- plugins/git/.claude-plugin/plugin.json | 2 +- plugins/git/.github/plugin/plugin.json | 2 +- plugins/git/apm.yml | 2 +- plugins/gitea/.claude-plugin/plugin.json | 2 +- plugins/gitea/.github/plugin/plugin.json | 2 +- plugins/gitea/apm.yml | 2 +- plugins/kyberforge/.claude-plugin/plugin.json | 2 +- plugins/kyberforge/.github/plugin/plugin.json | 2 +- plugins/kyberforge/apm.yml | 2 +- plugins/lint/.claude-plugin/plugin.json | 2 +- plugins/lint/.github/plugin/plugin.json | 2 +- plugins/lint/apm.yml | 2 +- 21 files changed, 41 insertions(+), 41 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f4c1ab1..4f11b93 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.5.0", + "version": "0.4.6", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.7.0", + "version": "1.6.1", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.2.0", + "version": "1.1.6", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.4.0", + "version": "1.3.6", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.4.0", + "version": "1.3.7", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.2.0", + "version": "1.1.2", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.2.0", + "version": "1.1.7", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index f4c1ab1..4f11b93 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.5.0", + "version": "0.4.6", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,35 +11,35 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.7.0", + "version": "1.6.1", "category": "Developer Tools", "source": "./plugins/kyberforge" }, { "name": "bin", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", - "version": "1.2.0", + "version": "1.1.6", "category": "Utilities", "source": "./plugins/bin" }, { "name": "git", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", - "version": "1.4.0", + "version": "1.3.6", "category": "Version Control", "source": "./plugins/git" }, { "name": "gitea", "description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.", - "version": "1.4.0", + "version": "1.3.7", "category": "Version Control", "source": "./plugins/gitea" }, { "name": "core", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", - "version": "1.2.0", + "version": "1.1.2", "category": "Productivity", "source": "./plugins/core" }, @@ -59,7 +59,7 @@ { "name": "lint", "description": "Skills and agents for configuring and running linters.", - "version": "1.2.0", + "version": "1.1.7", "category": "Developer Tools", "source": "./plugins/lint" } diff --git a/apm.yml b/apm.yml index 5d3ac05..4584124 100644 --- a/apm.yml +++ b/apm.yml @@ -1,5 +1,5 @@ name: holocron -version: 0.5.0 +version: 0.4.6 description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. license: MIT @@ -42,7 +42,7 @@ dependencies: # after a kyberforge release, check this first. executables: allow: - kyberforge#1.7.0: + kyberforge#1.6.1: hooks: true bin: true @@ -52,7 +52,7 @@ marketplace: # top-level apm.yml description:/version: above are NOT inherited into the # compiled output despite being used elsewhere (e.g. by `apm audit`). description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. - version: 0.5.0 + version: 0.4.6 owner: name: Defame1297 email: defame1297@rkdr.net @@ -79,31 +79,31 @@ marketplace: - name: kyberforge description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. source: ./plugins/kyberforge - version: 1.7.0 + version: 1.6.1 category: Developer Tools - name: bin description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. source: ./plugins/bin - version: 1.2.0 + version: 1.1.6 category: Utilities - name: git description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it. source: ./plugins/git - version: 1.4.0 + version: 1.3.6 category: Version Control - name: gitea description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone. source: ./plugins/gitea - version: 1.4.0 + version: 1.3.7 category: Version Control - name: core description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. source: ./plugins/core - version: 1.2.0 + version: 1.1.2 category: Productivity - name: mattpocock-skills @@ -115,5 +115,5 @@ marketplace: - name: lint description: Skills and agents for configuring and running linters. source: ./plugins/lint - version: 1.2.0 + version: 1.1.7 category: Developer Tools diff --git a/plugins/bin/.claude-plugin/plugin.json b/plugins/bin/.claude-plugin/plugin.json index 8a181b6..e1e71ee 100644 --- a/plugins/bin/.claude-plugin/plugin.json +++ b/plugins/bin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.2.0", + "version": "1.1.6", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/.github/plugin/plugin.json b/plugins/bin/.github/plugin/plugin.json index a0c4e43..5f8e98a 100644 --- a/plugins/bin/.github/plugin/plugin.json +++ b/plugins/bin/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bin", - "version": "1.2.0", + "version": "1.1.6", "description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.", "author": { "name": "Defame1297", diff --git a/plugins/bin/apm.yml b/plugins/bin/apm.yml index 69d4adf..eec10b1 100644 --- a/plugins/bin/apm.yml +++ b/plugins/bin/apm.yml @@ -1,5 +1,5 @@ name: bin -version: 1.2.0 +version: 1.1.6 description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin. author: name: Defame1297 diff --git a/plugins/core/.claude-plugin/plugin.json b/plugins/core/.claude-plugin/plugin.json index 607b382..4eb24cf 100644 --- a/plugins/core/.claude-plugin/plugin.json +++ b/plugins/core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.2.0", + "version": "1.1.2", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/.github/plugin/plugin.json b/plugins/core/.github/plugin/plugin.json index 607b382..4eb24cf 100644 --- a/plugins/core/.github/plugin/plugin.json +++ b/plugins/core/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "1.2.0", + "version": "1.1.2", "description": "Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it.", "author": { "name": "Defame1297", diff --git a/plugins/core/apm.yml b/plugins/core/apm.yml index 191931c..a30c043 100644 --- a/plugins/core/apm.yml +++ b/plugins/core/apm.yml @@ -1,5 +1,5 @@ name: core -version: 1.2.0 +version: 1.1.2 description: Skills for authoring and auditing a repo's AGENTS.md and the provider adapter files that defer to it. author: name: Defame1297 diff --git a/plugins/git/.claude-plugin/plugin.json b/plugins/git/.claude-plugin/plugin.json index b3643a4..bba9797 100644 --- a/plugins/git/.claude-plugin/plugin.json +++ b/plugins/git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git", - "version": "1.4.0", + "version": "1.3.6", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", "author": { "name": "Defame1297", diff --git a/plugins/git/.github/plugin/plugin.json b/plugins/git/.github/plugin/plugin.json index b3643a4..bba9797 100644 --- a/plugins/git/.github/plugin/plugin.json +++ b/plugins/git/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git", - "version": "1.4.0", + "version": "1.3.6", "description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.", "author": { "name": "Defame1297", diff --git a/plugins/git/apm.yml b/plugins/git/apm.yml index 2541baf..3201a1f 100644 --- a/plugins/git/apm.yml +++ b/plugins/git/apm.yml @@ -1,5 +1,5 @@ name: git -version: 1.4.0 +version: 1.3.6 description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it. author: name: Defame1297 diff --git a/plugins/gitea/.claude-plugin/plugin.json b/plugins/gitea/.claude-plugin/plugin.json index 3b97d12..10dd4fd 100644 --- a/plugins/gitea/.claude-plugin/plugin.json +++ b/plugins/gitea/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gitea", - "version": "1.4.0", + "version": "1.3.7", "description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.", "author": { "name": "Defame1297", diff --git a/plugins/gitea/.github/plugin/plugin.json b/plugins/gitea/.github/plugin/plugin.json index 3b97d12..10dd4fd 100644 --- a/plugins/gitea/.github/plugin/plugin.json +++ b/plugins/gitea/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gitea", - "version": "1.4.0", + "version": "1.3.7", "description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.", "author": { "name": "Defame1297", diff --git a/plugins/gitea/apm.yml b/plugins/gitea/apm.yml index 907f6e5..3150647 100644 --- a/plugins/gitea/apm.yml +++ b/plugins/gitea/apm.yml @@ -1,5 +1,5 @@ name: gitea -version: 1.4.0 +version: 1.3.7 description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone. author: name: Defame1297 diff --git a/plugins/kyberforge/.claude-plugin/plugin.json b/plugins/kyberforge/.claude-plugin/plugin.json index 85eef5e..b68cafa 100644 --- a/plugins/kyberforge/.claude-plugin/plugin.json +++ b/plugins/kyberforge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.7.0", + "version": "1.6.1", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/.github/plugin/plugin.json b/plugins/kyberforge/.github/plugin/plugin.json index 85eef5e..b68cafa 100644 --- a/plugins/kyberforge/.github/plugin/plugin.json +++ b/plugins/kyberforge/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kyberforge", - "version": "1.7.0", + "version": "1.6.1", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", "author": { "name": "Defame1297", diff --git a/plugins/kyberforge/apm.yml b/plugins/kyberforge/apm.yml index 9effd10..fc1e122 100644 --- a/plugins/kyberforge/apm.yml +++ b/plugins/kyberforge/apm.yml @@ -1,5 +1,5 @@ name: kyberforge -version: 1.7.0 +version: 1.6.1 description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. author: name: Defame1297 diff --git a/plugins/lint/.claude-plugin/plugin.json b/plugins/lint/.claude-plugin/plugin.json index 926c0c8..304e36f 100644 --- a/plugins/lint/.claude-plugin/plugin.json +++ b/plugins/lint/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lint", - "version": "1.2.0", + "version": "1.1.7", "description": "Skills and agents for configuring and running linters.", "author": { "name": "Defame1297", diff --git a/plugins/lint/.github/plugin/plugin.json b/plugins/lint/.github/plugin/plugin.json index 926c0c8..304e36f 100644 --- a/plugins/lint/.github/plugin/plugin.json +++ b/plugins/lint/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lint", - "version": "1.2.0", + "version": "1.1.7", "description": "Skills and agents for configuring and running linters.", "author": { "name": "Defame1297", diff --git a/plugins/lint/apm.yml b/plugins/lint/apm.yml index 452ca65..e10c52e 100644 --- a/plugins/lint/apm.yml +++ b/plugins/lint/apm.yml @@ -1,5 +1,5 @@ name: lint -version: 1.2.0 +version: 1.1.7 description: Skills and agents for configuring and running linters. author: name: Defame1297 -- 2.43.0 From afadaae7930695d314ba44d590a3ef0e22971771 Mon Sep 17 00:00:00 2001 From: Defame1297 <gitea@rkdr.net> Date: Tue, 1 Sep 2026 13:32:31 +0000 Subject: [PATCH 89/89] fix(lint): reword the two Vale errors this branch put where nothing lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Kyberforge.SentenceOpenerThereIs` is `level: error`, but `.vale.ini` scopes the style to `[**/SKILL.md]`, so no hook can see a violation in `references/`. This branch's own remedy — move prose out of the body into `references/` — is what carried these two there: - `provider-adapter-author/references/provider-matrix.md:31` - `agent-audit/references/finding-criteria.md:95` Both confirmed by copying the file to `SKILL.md` and running the audit's own `vale-wrap.sh`: 1 error each before, 0 after. Reworded to name the subject directly, which is what the rule asks for. Five further occurrences exist in `references/` files already on `main`. Those are the pre-existing corpus, not this branch's regression, and stay with #117 — which is the real fix: widen the Vale scope so the gate can see this class at all, and make the wrapper distinguish "0 alerts in 1 file" from "0 files matched". Source and generated mirror move together so the commit passes `check-plugin-content-sync` standalone. Refs: #117 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ktx4QJzTXZtw35m6T9WhcK --- .../provider-adapter-author/references/provider-matrix.md | 4 ++-- .../provider-adapter-author/references/provider-matrix.md | 4 ++-- .../.apm/skills/agent-audit/references/finding-criteria.md | 2 +- .../skills/agent-audit/references/finding-criteria.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md index b747538..e03bbf8 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md @@ -28,5 +28,5 @@ This skill is reached two ways: invoked directly by a user, and composed into by once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and run the closeout validator yourself, rather than treating any step as already done by the caller or -as something the caller will do afterwards. There is no handshake to rely on and no state passed -in beyond the file paths. +as something the caller will do afterwards. No handshake exists to rely on, and no state is +passed in beyond the file paths. diff --git a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md index b747538..e03bbf8 100644 --- a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md @@ -28,5 +28,5 @@ This skill is reached two ways: invoked directly by a user, and composed into by once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and run the closeout validator yourself, rather than treating any step as already done by the caller or -as something the caller will do afterwards. There is no handshake to rely on and no state passed -in beyond the file paths. +as something the caller will do afterwards. No handshake exists to rely on, and no state is +passed in beyond the file paths. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md index 91e431a..5cd8509 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/finding-criteria.md @@ -92,7 +92,7 @@ Flag as SUGGESTION if: **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. There is no number +system prompt of a fresh context rather than competing with a live conversation. No number exists to cite. The one length signal that applies is the Copilot runtime's 30,000-character body limit, which `validate.sh` already reports as a SUGGESTION. Length is judged through the delegation FAIL above instead. diff --git a/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md index 91e431a..5cd8509 100644 --- a/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md +++ b/plugins/kyberforge/skills/agent-audit/references/finding-criteria.md @@ -92,7 +92,7 @@ Flag as SUGGESTION if: **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. There is no number +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. -- 2.43.0