7 Commits
Author SHA1 Message Date
Defame1297andClaude Opus 5 971e148e19 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:47:04 +00:00
Defame1297andClaude Opus 5 4011d149bc 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:47:00 +00:00
Defame1297andClaude Opus 5 ae791781c2 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:46:35 +00:00
Defame1297andClaude Opus 5 1a971ee003 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:46:32 +00:00
Defame1297andClaude Opus 5 a8cd5e881d 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:46:07 +00:00
Defame1297andClaude Opus 5 00daf285ec 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:46:05 +00:00
Defame1297andClaude Opus 5 c232e69645 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EJJrm5YmacbwMdzZpXcoti
2026-08-31 19:45:45 +00:00
88 changed files with 1504 additions and 241 deletions

No files matched your search

+1 -1
View File
@@ -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`. - **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. - **`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. - **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. - **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. - **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. - **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.
+8
View File
@@ -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`. and the target-resolution walk: `docs/spec/gates.md`.
_Avoid_: skill budget, size limit _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**: **Dispatch body**:
The body pattern a skill with two or more mutually exclusive flows must use — the body carries only 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 the dispatch table and the gates common to every branch, and each flow lives in its own
@@ -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 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 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 at `plugins/bin/skills/zoom-out/SKILL.md:4`, and `zoom-out` was — at the time of that check, when it
the model-visible skill listing in a live session. It remains invocable as `/zoom-out`. 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 ### 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 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, 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 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 after normalising `skill`/`agent`, where before they were closer. It has kept deepening since: the
reopens ADR-0008 (agent-audit's single-file invocation contract) and touches every call site in #99 retrofit added `finding-criteria.md` to `skill-audit`, drawing it level with `agent-audit`. Both
`skill-author`, `agent-author` and `forge`, which is why it is its own change and not a rider on figures move with the next retrofit, so measure rather than quote —
this one. Recorded here rather than dropped, so the gap between the rule and the tree is deliberate `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. and dated instead of discovered later.
### Enforcement and rollout ### 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 | | 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` | | 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 | | 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 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 (SUGGESTION) | 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 entry count over five (SUGGESTION) | skills | deterministic | same |
| Gotchas over 25% of the body (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 | | 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 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. 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 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 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`, 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 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 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 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 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-` / 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 `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 - `description-quality.md:45-50` has no FAIL condition for internal-mechanics content, which is why
`skill-author/SKILL.md:102` never bit. `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 ## Consequences
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39 **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 — 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. 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 **Four broken routing targets were found; two were fixed here and two shortly after.** Tracked as
#100. issue #100.
- `skill-audit` routed to `/skill-improve` twice in its description plus `README.md:10`, and no such - `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 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 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 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. 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 - `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. stray space introduced by YAML wrapping mid-token, breaking the skill name in preloaded text. Was
**Live** — the check reports it as a dangling `gitea-labels`. **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 So the check fired on 3 of the 4 against the base commit and on 2 at the tip of the change that
`tests/test-skill-size-check.sh` probes exactly those three by name rather than asserting a count, so carried this ADR. **The corpus dangling set is now empty**, and that is asserted rather than
it degrades to SKIP as #100 lands rather than going stale. 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 **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 deliberately excludes the author pair, so the commit-verification argument in four near-copies, the
+90 -4
View File
@@ -98,11 +98,57 @@ loudly (`Error: jq is required but not installed`).
## Skill and agent context gates (ADR-0020) ## Skill and agent context gates (ADR-0020)
The `skill-size-check` pre-commit hook, scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$`, 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 runs `scripts/skill-size-check.sh`. It is also shipped to external repos as
`plugins/kyberforge/docs/research/examples/` reference skills. It is also shipped to external repos `kyberforge-skill-size-check` (see
as `kyberforge-skill-size-check` (see
[External consumers](#external-consumers-the-root-pre-commit-hooksyaml)). [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 ### Two independent gate families, neither replaced the other
**Family 1 — agentskills.io spec backstop** (unchanged, conformance not quality): **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 | | Verdict | When |
|---|---| |---|---|
| **SUGGESTION** — the default | the target does not resolve and neither promotion condition below holds | | **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 | | **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 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 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. 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 **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 `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. 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, 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. 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` ### `vale-wrap.sh`, never bare `vale`
Both audit skills' Step 1 and both pre-commit hooks call **each copy's own** Both audit skills' Step 1 and both pre-commit hooks call **each copy's own**
+5 -1
View File
@@ -1,6 +1,10 @@
--- ---
name: grill-me 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. 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.
@@ -1,6 +1,9 @@
--- ---
name: grill-with-docs 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> <what-to-do>
@@ -1,6 +1,10 @@
--- ---
name: improve-codebase-architecture 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 # Improve Codebase Architecture
@@ -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. - **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. - **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". - **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.
+3 -3
View File
@@ -12,7 +12,7 @@ The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably
## Composition ## 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 ## Usage
@@ -27,5 +27,5 @@ Name the topic and the output path — the skill will stop and ask if the path i
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `SKILL.md` | The four gotchas and the six research steps | | `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/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 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | | `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 |
+17 -5
View File
@@ -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. 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 ## 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 ## 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 - `<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 in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` - `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. If no topic area has content, write nothing at all, `sources.md` included, and report what was searched.
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: tdd 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 # Test-Driven Development
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: triage 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 # Triage
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: write-docs 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" version: "1.0"
updated: 2026-05-17 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 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
+5 -1
View File
@@ -1,6 +1,10 @@
--- ---
name: grill-me 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. 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.
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: grill-with-docs 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> <what-to-do>
@@ -1,6 +1,10 @@
--- ---
name: improve-codebase-architecture 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 # Improve Codebase Architecture
@@ -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. - **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. - **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". - **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.
+3 -3
View File
@@ -12,7 +12,7 @@ The frontmatter pins `model: sonnet` and a closed `allowed-tools` list. Notably
## Composition ## 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 ## Usage
@@ -27,5 +27,5 @@ Name the topic and the output path — the skill will stop and ask if the path i
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `SKILL.md` | The four gotchas and the six research steps | | `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/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 before writing: the frontmatter schema for a topic file and the exact `sources.md` field names the provenance validator matches | | `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 |
+17 -5
View File
@@ -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. 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 ## 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 ## 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 - `<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 in the schema that file gives — URL, description, contributing files, and status — including sources that yielded nothing, marked `no content extracted` - `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. If no topic area has content, write nothing at all, `sources.md` included, and report what was searched.
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: tdd 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 # Test-Driven Development
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: triage 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 # Triage
+4 -1
View File
@@ -1,6 +1,9 @@
--- ---
name: write-docs 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" version: "1.0"
updated: 2026-05-17 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 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
@@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `SKILL.md` | Skill instructions for agents | | `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 | | `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/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin |
| `scripts/README.md` | Directory documentation for `scripts/` | | `scripts/README.md` | Directory documentation for `scripts/` |
@@ -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. - 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 ## 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. 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: 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. - **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. 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> 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 ## Step 4 — Report
@@ -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 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`. 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.
@@ -31,6 +31,12 @@ Exit codes:
0 Adapter file passes all checks 0 Adapter file passes all checks
1 One or more checks failed (empty file, no reference to AGENTS.md, 1 One or more checks failed (empty file, no reference to AGENTS.md,
excessive duplication, or file too long) 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 EOF
} }
@@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do
--max-lines) --max-lines)
if [[ $# -lt 2 ]]; then if [[ $# -lt 2 ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2 echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 1 exit 2
fi fi
MAX_LINES="$2" MAX_LINES="$2"
if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2
exit 1 exit 2
fi fi
shift 2 shift 2
;; ;;
@@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then
echo "Error: adapter-file and agents-md-file are required." >&2 echo "Error: adapter-file and agents-md-file are required." >&2
echo "" >&2 echo "" >&2
usage >&2 usage >&2
exit 1 exit 2
fi fi
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' 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): if not os.path.isfile(adapter_path):
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) 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): if not os.path.isfile(agents_md_path):
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) 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() def read_text(path):
with open(agents_md_path, encoding="utf-8", errors="replace") as f: r"""File contents as text, UTF-8, BOM stripped.
agents_md_content = f.read()
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 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(" 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(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
else: else:
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") 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: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") 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() print()
# --- Duplication check --- # --- Duplication check ---
@@ -148,7 +148,7 @@ EOF
ADAPTER="$TMPDIR/CLAUDE.md" ADAPTER="$TMPDIR/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER" echo "@AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" --max-lines run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" --max-lines
assert_failure assert_failure 2
assert_output --partial "--max-lines requires a value" assert_output --partial "--max-lines requires a value"
} }
@@ -156,7 +156,7 @@ EOF
ADAPTER="$TMPDIR/CLAUDE.md" ADAPTER="$TMPDIR/CLAUDE.md"
echo "@AGENTS.md" > "$ADAPTER" echo "@AGENTS.md" > "$ADAPTER"
run bash "$SCRIPT" --max-lines abc "$ADAPTER" "$AGENTS_MD" run bash "$SCRIPT" --max-lines abc "$ADAPTER" "$AGENTS_MD"
assert_failure assert_failure 2
assert_output --partial "non-negative integer" assert_output --partial "non-negative integer"
} }
@@ -168,6 +168,47 @@ EOF
@test "fails with a clear error when the adapter file argument is missing" { @test "fails with a clear error when the adapter file argument is missing" {
run bash "$SCRIPT" run bash "$SCRIPT"
assert_failure assert_failure 2
assert_output --partial "required" 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"
}
@@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `SKILL.md` | Skill instructions for agents | | `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 | | `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/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin |
| `scripts/README.md` | Directory documentation for `scripts/` | | `scripts/README.md` | Directory documentation for `scripts/` |
@@ -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. - 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 ## 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. 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: 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. - **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. 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> 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 ## Step 4 — Report
@@ -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 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`. 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.
@@ -31,6 +31,12 @@ Exit codes:
0 Adapter file passes all checks 0 Adapter file passes all checks
1 One or more checks failed (empty file, no reference to AGENTS.md, 1 One or more checks failed (empty file, no reference to AGENTS.md,
excessive duplication, or file too long) 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 EOF
} }
@@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do
--max-lines) --max-lines)
if [[ $# -lt 2 ]]; then if [[ $# -lt 2 ]]; then
echo "Error: --max-lines requires a value (a non-negative integer)." >&2 echo "Error: --max-lines requires a value (a non-negative integer)." >&2
exit 1 exit 2
fi fi
MAX_LINES="$2" MAX_LINES="$2"
if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then
echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2
exit 1 exit 2
fi fi
shift 2 shift 2
;; ;;
@@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then
echo "Error: adapter-file and agents-md-file are required." >&2 echo "Error: adapter-file and agents-md-file are required." >&2
echo "" >&2 echo "" >&2
usage >&2 usage >&2
exit 1 exit 2
fi fi
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' 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): if not os.path.isfile(adapter_path):
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) 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): if not os.path.isfile(agents_md_path):
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) 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() def read_text(path):
with open(agents_md_path, encoding="utf-8", errors="replace") as f: r"""File contents as text, UTF-8, BOM stripped.
agents_md_content = f.read()
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 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(" 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(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
else: else:
print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") 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: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") 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() print()
# --- Duplication check --- # --- Duplication check ---
@@ -20,7 +20,7 @@ metadata:
## Gotchas ## 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. - **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. - **`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 ## Step 1 — Determine the branching pattern
+10 -1
View File
@@ -57,8 +57,17 @@ For an agent caller, return:
"semver_impact": "MAJOR|MINOR|PATCH|none", "semver_impact": "MAJOR|MINOR|PATCH|none",
"breaking_change": false, "breaking_change": false,
"confirmation_required": 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 <[email protected]>"]
}
} }
``` ```
`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. For a human caller, show the same fields as a prose preview with a confirmation prompt.
+4 -4
View File
@@ -2,10 +2,10 @@
name: git-history name: git-history
description: > description: >
Use when investigating git history — querying logs, tracing when a change Use when investigating git history — pickaxe (`-S`/`-G`) or `-L` line-range log
landed, bisecting the commit that broke something, or locating one to queries, tracing when a change landed, bisecting what broke something, or
revert or backport. Not authoring or rebasing commits -> `git-commits`. locating a commit to revert or backport. Not authoring or rebasing commits ->
Not history on a Gitea server -> `gitea-branches`. `git-commits`. Not a Gitea server's history -> `gitea-branches`.
metadata: metadata:
category: git category: git
@@ -6,6 +6,7 @@ description: >
remote, even when the user does not name it. remote, even when the user does not name it.
Not local commits -> `git-commits`. Not local commits -> `git-commits`.
Not local branches -> `git-branches`. Not local branches -> `git-branches`.
Not log or bisect queries -> `git-history`.
Not submodule pointers -> `git-submodules`. Not submodule pointers -> `git-submodules`.
metadata: metadata:
@@ -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 | | `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table |
| `references/README.md` | Describes contents of references/ | | `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/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/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 | | `references/sources.md` | Research sources and provenance |
@@ -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 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 `<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 ## Dispatch
@@ -42,7 +44,7 @@ Read only the row that matches the request.
| Task | Reference | | 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` | | 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` | | Remove a submodule, or `deinit` one without removing it | `references/removal.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 ## setup-and-update.md
Cloning a superproject that has submodules, adding a dependency as a submodule, initializing 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 without cloning, updating or re-pinning, and running one command across every submodule. Carries
keep-pinned and move-the-pin-forward workflows. 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 ## urls-and-config.md
@@ -11,7 +11,7 @@ Both operations are destructive. Confirm with the user before executing either.
## `deinit` is not removal ## `deinit` is not removal
```bash ```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 `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 ## Full removal, in order
```bash ```bash
git submodule deinit -f <path> # unregister from .git/config rtk git submodule deinit -f <path> # unregister from .git/config
git rm <path> # drop the .gitmodules entry and the gitlink from the index 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 rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git
git commit -m "chore: remove <name> submodule" 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. is present Git refuses to add a submodule at the same path again.
@@ -9,16 +9,16 @@ source_keys:
## Clone a superproject that already has submodules ## Clone a superproject that already has submodules
```bash ```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 # or, against an existing clone
git submodule update --init --recursive rtk git submodule update --init --recursive
``` ```
## Add a dependency as a submodule ## Add a dependency as a submodule
```bash ```bash
git submodule add <url> <path> rtk git submodule add <url> <path>
git commit -m "chore: add <name> as submodule" rtk git commit -m "chore: add <name> as submodule"
``` ```
`add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: `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 ## 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 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 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`. Use `update --init` to run both steps at once.
## Update ## 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. out the commit the superproject recorded, in detached HEAD.
| Flag | Meaning | | Flag | Meaning |
@@ -59,16 +59,38 @@ out the commit the superproject recorded, in detached HEAD.
## Keep submodules pinned to the recorded commit ## Keep submodules pinned to the recorded commit
```bash ```bash
git submodule update --recursive # after every git pull rtk git submodule update --recursive # after every rtk git pull
git config submodule.recurse true # or do it automatically on pull/push/checkout rtk git config submodule.recurse true # or do it automatically on pull/push/checkout
``` ```
## Move the pin forward to the tracked branch tip ## Move the pin forward to the tracked branch tip
```bash ```bash
git submodule update --remote --merge --recursive rtk git submodule update --remote --merge --recursive
git commit -am "chore: update submodules to latest" rtk git commit -am "chore: update submodules to latest"
``` ```
`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default `--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`. 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 |
@@ -10,7 +10,7 @@ source_keys:
- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's - **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's
logical name, path, and canonical URL. 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. and never propagate to another clone.
The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is 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 ## Rebind a URL or branch
```bash ```bash
git submodule sync --recursive # push .gitmodules URLs into .git/config rtk git submodule sync --recursive # push .gitmodules URLs into .git/config
git submodule set-url <path> <url> # change the canonical URL rtk 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 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 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) ## Override a URL locally (private mirror)
```bash ```bash
git submodule init rtk git submodule init
# edit .git/config: submodule.<name>.url = <mirror-url> # 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`. Local-only, invisible to collaborators, and overwritten by the next `sync`.
@@ -65,15 +65,15 @@ everywhere else.
## Custom `update` commands are security-gated ## Custom `update` commands are security-gated
A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by 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. arbitrary code. Setting it locally in `.git/config` is the only way to enable it.
## Relocate an embedded `.git` directory ## Relocate an embedded `.git` directory
```bash ```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 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 file behind. Needed when a nested repository was created or copied in without going through
`git submodule add`. `rtk git submodule add`.
@@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp
## What it does ## 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 ## Usage
@@ -18,7 +18,7 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma
| File | Purpose | | 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 | | `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/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/README.md` | Describes the references directory contents |
+22 -4
View File
@@ -3,8 +3,9 @@ name: git-workflow
description: > description: >
Use when a human's local git request is general or ambiguous — it routes to the owning 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 -> domain skill: `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules`
`git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. or `git-worktrees`. An unambiguous request goes straight to its domain skill instead. Not an
agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`.
metadata: metadata:
category: git category: git
@@ -25,10 +26,27 @@ metadata:
mandated org wrapper, not a style preference. Submodule-specific commands run from inside the mandated org wrapper, not a style preference. Submodule-specific commands run from inside the
submodule's own directory instead. 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 ## Workflow
1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) 1. **Parse intent** — extract the operation and, from the table above, the domain that owns it,
and any options the user named. plus any options the user named.
2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or 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 touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule
before acting, not after. before acting, not after.
@@ -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. - **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. - **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. - **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 ## Step 1 — Dispatch
+3
View File
@@ -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` | | "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` | | "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 ## 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. 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.
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
## Gotchas ## 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. - **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. - **`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 ## Step 1 — Determine the branching pattern
+10 -1
View File
@@ -57,8 +57,17 @@ For an agent caller, return:
"semver_impact": "MAJOR|MINOR|PATCH|none", "semver_impact": "MAJOR|MINOR|PATCH|none",
"breaking_change": false, "breaking_change": false,
"confirmation_required": 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 <[email protected]>"]
}
} }
``` ```
`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. For a human caller, show the same fields as a prose preview with a confirmation prompt.
+4 -4
View File
@@ -2,10 +2,10 @@
name: git-history name: git-history
description: > description: >
Use when investigating git history — querying logs, tracing when a change Use when investigating git history — pickaxe (`-S`/`-G`) or `-L` line-range log
landed, bisecting the commit that broke something, or locating one to queries, tracing when a change landed, bisecting what broke something, or
revert or backport. Not authoring or rebasing commits -> `git-commits`. locating a commit to revert or backport. Not authoring or rebasing commits ->
Not history on a Gitea server -> `gitea-branches`. `git-commits`. Not a Gitea server's history -> `gitea-branches`.
metadata: metadata:
category: git category: git
+1
View File
@@ -6,6 +6,7 @@ description: >
remote, even when the user does not name it. remote, even when the user does not name it.
Not local commits -> `git-commits`. Not local commits -> `git-commits`.
Not local branches -> `git-branches`. Not local branches -> `git-branches`.
Not log or bisect queries -> `git-history`.
Not submodule pointers -> `git-submodules`. Not submodule pointers -> `git-submodules`.
metadata: metadata:
+1 -1
View File
@@ -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 | | `SKILL.md` | Skill instructions for agents — gotchas, shared working rules, and the task dispatch table |
| `references/README.md` | Describes contents of references/ | | `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/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/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 | | `references/sources.md` | Research sources and provenance |
+4 -2
View File
@@ -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 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 `<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 ## Dispatch
@@ -42,7 +44,7 @@ Read only the row that matches the request.
| Task | Reference | | 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` | | 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` | | Remove a submodule, or `deinit` one without removing it | `references/removal.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 ## setup-and-update.md
Cloning a superproject that has submodules, adding a dependency as a submodule, initializing 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 without cloning, updating or re-pinning, and running one command across every submodule. Carries
keep-pinned and move-the-pin-forward workflows. 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 ## urls-and-config.md
@@ -11,7 +11,7 @@ Both operations are destructive. Confirm with the user before executing either.
## `deinit` is not removal ## `deinit` is not removal
```bash ```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 `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 ## Full removal, in order
```bash ```bash
git submodule deinit -f <path> # unregister from .git/config rtk git submodule deinit -f <path> # unregister from .git/config
git rm <path> # drop the .gitmodules entry and the gitlink from the index 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 rm -rf .git/modules/<name>/ # stale git dir: not tracked, not cleaned up by git
git commit -m "chore: remove <name> submodule" 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. is present Git refuses to add a submodule at the same path again.
@@ -9,16 +9,16 @@ source_keys:
## Clone a superproject that already has submodules ## Clone a superproject that already has submodules
```bash ```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 # or, against an existing clone
git submodule update --init --recursive rtk git submodule update --init --recursive
``` ```
## Add a dependency as a submodule ## Add a dependency as a submodule
```bash ```bash
git submodule add <url> <path> rtk git submodule add <url> <path>
git commit -m "chore: add <name> as submodule" rtk git commit -m "chore: add <name> as submodule"
``` ```
`add` stages a `.gitmodules` entry and a gitlink — the commit is still required. Flags: `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 ## 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 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 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`. Use `update --init` to run both steps at once.
## Update ## 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. out the commit the superproject recorded, in detached HEAD.
| Flag | Meaning | | Flag | Meaning |
@@ -59,16 +59,38 @@ out the commit the superproject recorded, in detached HEAD.
## Keep submodules pinned to the recorded commit ## Keep submodules pinned to the recorded commit
```bash ```bash
git submodule update --recursive # after every git pull rtk git submodule update --recursive # after every rtk git pull
git config submodule.recurse true # or do it automatically on pull/push/checkout rtk git config submodule.recurse true # or do it automatically on pull/push/checkout
``` ```
## Move the pin forward to the tracked branch tip ## Move the pin forward to the tracked branch tip
```bash ```bash
git submodule update --remote --merge --recursive rtk git submodule update --remote --merge --recursive
git commit -am "chore: update submodules to latest" rtk git commit -am "chore: update submodules to latest"
``` ```
`--remote` requires `submodule.<name>.branch`; without it Git falls back to the remote's default `--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`. 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 |
@@ -10,7 +10,7 @@ source_keys:
- **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's - **`.gitmodules`** — version-controlled, shared with collaborators. Defines each submodule's
logical name, path, and canonical URL. 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. and never propagate to another clone.
The submodule's own `.git` directory lives at `.git/modules/<name>/` in the superproject and is 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 ## Rebind a URL or branch
```bash ```bash
git submodule sync --recursive # push .gitmodules URLs into .git/config rtk git submodule sync --recursive # push .gitmodules URLs into .git/config
git submodule set-url <path> <url> # change the canonical URL rtk 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 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 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) ## Override a URL locally (private mirror)
```bash ```bash
git submodule init rtk git submodule init
# edit .git/config: submodule.<name>.url = <mirror-url> # 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`. Local-only, invisible to collaborators, and overwritten by the next `sync`.
@@ -65,15 +65,15 @@ everywhere else.
## Custom `update` commands are security-gated ## Custom `update` commands are security-gated
A `.gitmodules` entry of `update = !some-command` is never copied into `.git/config` by 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. arbitrary code. Setting it locally in `.git/config` is the only way to enable it.
## Relocate an embedded `.git` directory ## Relocate an embedded `.git` directory
```bash ```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 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 file behind. Needed when a nested repository was created or copied in without going through
`git submodule add`. `rtk git submodule add`.
+2 -2
View File
@@ -4,7 +4,7 @@ Human-friendly interface for interactive git workflows with conversational promp
## What it does ## 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 ## Usage
@@ -18,7 +18,7 @@ Describe your git workflow: commit, create a branch, rebase, inspect history, ma
| File | Purpose | | 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 | | `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/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/README.md` | Describes the references directory contents |
+22 -4
View File
@@ -3,8 +3,9 @@ name: git-workflow
description: > description: >
Use when a human's local git request is general or ambiguous — it routes to the owning 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 -> domain skill: `git-commits`, `git-branches`, `git-history`, `git-remotes`, `git-submodules`
`git-branches`. Not an agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`. or `git-worktrees`. An unambiguous request goes straight to its domain skill instead. Not an
agent caller -> `git-orchestrate`. Not Gitea -> `gitea-workflow`.
metadata: metadata:
category: git category: git
@@ -25,10 +26,27 @@ metadata:
mandated org wrapper, not a style preference. Submodule-specific commands run from inside the mandated org wrapper, not a style preference. Submodule-specific commands run from inside the
submodule's own directory instead. 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 ## Workflow
1. **Parse intent** — extract the operation (commit, create branch, rebase, inspect history, …) 1. **Parse intent** — extract the operation and, from the table above, the domain that owns it,
and any options the user named. plus any options the user named.
2. **Check the hard rules** — if the request creates, amends, or rewrites a commit, pushes, or 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 touches hooks, config, or credentials, read `references/hard-rules.md`. Raise the relevant rule
before acting, not after. before acting, not after.
+1 -1
View File
@@ -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. - **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. - **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. - **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 ## Step 1 — Dispatch
+3
View File
@@ -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` | | "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` | | "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 ## 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. 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.
@@ -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. - **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`. - **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 ## Step 1 — Resolve owner and repo
@@ -3,8 +3,8 @@ name: gitea-files
description: > description: >
Use when reading or writing files or directories in a Gitea repository via the MCP server, 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 rather than the local filesystem (for a local path use Read/Write/Edit) — even when the user
history -> `gitea-branches`. Not pull requests -> `gitea-prs`. 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 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 write:repository. Tested with a token holding write:issue + write:repository; write:issue
@@ -2,13 +2,13 @@
## gitea-mcp-repo ## 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 **Source:** https://gitea.com/gitea/gitea-mcp
- **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **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) - SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch)
- references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) - 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) - 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 - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md
**Contributing files:** (none) - **Contributing files:** (none)
@@ -2,8 +2,9 @@
name: gitea-issues name: gitea-issues
description: > description: >
Use when reading or writing Gitea issues — even when the user does not say "Gitea". Use when reading or writing Gitea issues — "create an issue", "what issues are open",
Not pull requests -> `gitea-prs`. "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`. Not label or milestone definitions -> `gitea-labels-milestones`.
compatibility: Requires Gitea MCP server configured with write:issue and write:repository token 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 ## 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. - **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. - **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 ## 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 ```bash
git remote get-url origin 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 ## 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. 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. 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.
@@ -2,8 +2,9 @@
name: gitea-labels-milestones name: gitea-labels-milestones
description: > description: >
Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer Use when reading or writing Gitea labels or milestones — "create a label", "what labels does
labels — even when the user does not say "Gitea". 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`. 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. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes.
+3 -1
View File
@@ -3,7 +3,9 @@ name: gitea-prs
description: > description: >
Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even 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 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 scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when
@@ -2,9 +2,11 @@
name: gitea-releases name: gitea-releases
description: > description: >
Use when managing Gitea releases or the git tags underneath them — list, get, Use when managing Gitea releases or the git tags underneath them — list, get, create, or
create, or delete either — even when the user does not say "release" or delete either — even when the user does not say "release" or "Gitea": "cut a v1.2.0",
"Gitea". Not branches or commit history -> `gitea-branches`. "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 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 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. `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 ## 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 | | 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 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. | | 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. | | List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. |
@@ -12,11 +12,20 @@ additional tool schemas.
## Release wraps a tag, not the reverse ## 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 A release is a title, body (notes), and draft/prerelease flags layered on top of a tag. The tag is
newly-created tag. The tag is the git-level object (a name pointing at a commit); the release is a the git-level object (a name pointing at a commit); the release is a Gitea-level metadata wrapper
Gitea-level metadata wrapper around it. This is why `delete_release` and `delete_tag` are separate around it. This is why `delete_release` and `delete_tag` are separate calls with separate
calls with separate identifiers (numeric id vs. tag name) — removing the wrapper never implies identifiers (numeric id vs. tag name) — removing the wrapper never implies removing the underlying
removing the underlying pointer, and vice versa. 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 ## Semver tag naming
@@ -5,7 +5,8 @@ description: >
Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that 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 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 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 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. domain skills, which in turn require write:issue and write:repository scopes at minimum.
+1 -1
View File
@@ -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. - **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`. - **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 ## Step 1 — Resolve owner and repo
+2 -2
View File
@@ -3,8 +3,8 @@ name: gitea-files
description: > description: >
Use when reading or writing files or directories in a Gitea repository via the MCP server, 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 rather than the local filesystem (for a local path use Read/Write/Edit) — even when the user
history -> `gitea-branches`. Not pull requests -> `gitea-prs`. 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 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 write:repository. Tested with a token holding write:issue + write:repository; write:issue
@@ -2,13 +2,13 @@
## gitea-mcp-repo ## 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 **Source:** https://gitea.com/gitea/gitea-mcp
- **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md - **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) - SKILL.md (Gotchas — cross-flow parameter and encoding traps; Dispatch)
- references/reading.md (read-tool parameters, `ref`/`tree_sha` selection, tree pagination) - 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) - 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 - **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md
**Contributing files:** (none) - **Contributing files:** (none)
+7 -6
View File
@@ -2,8 +2,9 @@
name: gitea-issues name: gitea-issues
description: > description: >
Use when reading or writing Gitea issues — even when the user does not say "Gitea". Use when reading or writing Gitea issues — "create an issue", "what issues are open",
Not pull requests -> `gitea-prs`. "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`. Not label or milestone definitions -> `gitea-labels-milestones`.
compatibility: Requires Gitea MCP server configured with write:issue and write:repository token 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 ## 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. - **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. - **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 ## 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 ```bash
git remote get-url origin 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 ## 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. 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. 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.
@@ -2,8 +2,9 @@
name: gitea-labels-milestones name: gitea-labels-milestones
description: > description: >
Use when reading or writing Gitea labels or milestones — resolve names to IDs, or infer Use when reading or writing Gitea labels or milestones — "create a label", "what labels does
labels — even when the user does not say "Gitea". 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`. 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. compatibility: Requires Gitea MCP server configured with write:issue and write:repository token scopes.
+3 -1
View File
@@ -3,7 +3,9 @@ name: gitea-prs
description: > description: >
Use when listing, reading, creating, updating, merging, or reviewing Gitea pull requests — even 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 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 scopes. Requires git remote "origin" pointing to the Gitea instance for owner/repo resolution when
+7 -5
View File
@@ -2,9 +2,11 @@
name: gitea-releases name: gitea-releases
description: > description: >
Use when managing Gitea releases or the git tags underneath them — list, get, Use when managing Gitea releases or the git tags underneath them — list, get, create, or
create, or delete either — even when the user does not say "release" or delete either — even when the user does not say "release" or "Gitea": "cut a v1.2.0",
"Gitea". Not branches or commit history -> `gitea-branches`. "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 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 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. `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 ## 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 | | 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 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. | | 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. | | List every page | Loop `page: 1, 2, 3...` until a response returns fewer than `per_page` entries. Nothing here auto-paginates. |
@@ -12,11 +12,20 @@ additional tool schemas.
## Release wraps a tag, not the reverse ## 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 A release is a title, body (notes), and draft/prerelease flags layered on top of a tag. The tag is
newly-created tag. The tag is the git-level object (a name pointing at a commit); the release is a the git-level object (a name pointing at a commit); the release is a Gitea-level metadata wrapper
Gitea-level metadata wrapper around it. This is why `delete_release` and `delete_tag` are separate around it. This is why `delete_release` and `delete_tag` are separate calls with separate
calls with separate identifiers (numeric id vs. tag name) — removing the wrapper never implies identifiers (numeric id vs. tag name) — removing the wrapper never implies removing the underlying
removing the underlying pointer, and vice versa. 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 ## Semver tag naming
+2 -1
View File
@@ -5,7 +5,8 @@ description: >
Use when a Gitea request is general or ambiguous — a no-args repo check-in, a bare number that 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 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 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 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. domain skills, which in turn require write:issue and write:repository scopes at minimum.
@@ -22,7 +22,10 @@ Checks performed:
0 source_keys present in agent pair but sources.md absent 0 source_keys present in agent pair but sources.md absent
1 FILL IN: placeholders in sources.md 1 FILL IN: placeholders in sources.md
2 source_keys in agent files → slug exists 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 4 Contributing files back-reference the parent slug in their source_keys
5 Research doc field present and not placeholder 5 Research doc field present and not placeholder
EOF EOF
@@ -244,14 +247,31 @@ has_fail = False
def emit_fail(desc, fpath, why, fix): def emit_fail(desc, fpath, why, fix):
global has_fail global has_fail
has_fail = True 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(): def print_findings():
for kind, desc, fpath, why, fix in findings: 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"FAIL {desc} — {fpath}")
print(f" Why: {why}") print(f" Why: {why}")
print(f" Fix: {fix}") print(f" Fix: {fix}")
print() print()
else:
print(f"INFO {desc} — {fpath}")
print(f" Note: {note}")
print()
# --- Collect source_keys from agent pair --- # --- Collect source_keys from agent pair ---
def get_source_keys_from_file(fpath): 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 --- # --- Checks 3, 4, 5: Per-slug checks in sources.md ---
for slug in parse_h2_slugs(sources_content): 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) 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: for cf_rel in cf_files:
cf_abs = os.path.join(plugin_root, cf_rel) cf_abs = os.path.join(plugin_root, cf_rel)
if not os.path.isfile(cf_abs): if not os.path.isfile(cf_abs):
@@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): 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) base = os.path.basename(path)
if base.endswith('.agent.md'): if base.endswith('.agent.md'):
base = base[:-len('.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 # ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so. # 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 # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional # still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution. # `<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_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) 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_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 # 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 # `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, # 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), _add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True) strict=True, arrow=True)
if boundary: 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): for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1)) _add(out, sentence, match.group(1), match.start(1), match.end(1))
return out return out
@@ -1110,7 +1175,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end()) end = masked.find('\n', match.end())
if end < 0: if end < 0:
end = len(masked) 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 continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]): if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue continue
@@ -433,3 +433,140 @@ EOF
run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md"
assert_success 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:"
}
@@ -21,7 +21,10 @@ Checks performed:
3 source_keys in references/*.md → slug exists in sources.md (INFO if no 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 source_keys; an explicit 'source_keys: []' declares the file house-authored
and passes silently) 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 5 Contributing files back-reference the parent slug in their source_keys
6 Research doc field present and not placeholder 6 Research doc field present and not placeholder
7 Slug in sources.md present in upstream research doc (INFO only). A section 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 # 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 # `source_keys:` with nothing after it is NOT accepted here — that reads as a
# truncated or half-written entry, not a decision. # 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): def declares_empty_source_keys(fm):
"""True when frontmatter carries an explicit, empty `source_keys: []`.""" """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 research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it
for slug in parse_h2_slugs(sources_content): 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) 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: for cf_rel in cf_files:
cf_abs = os.path.join(skill_dir, cf_rel) cf_abs = os.path.join(skill_dir, cf_rel)
if not os.path.isfile(cf_abs): if not os.path.isfile(cf_abs):
@@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): 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) base = os.path.basename(path)
if base.endswith('.agent.md'): if base.endswith('.agent.md'):
base = base[:-len('.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 # ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so. # 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 # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional # still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution. # `<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_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) 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_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 # 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 # `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, # 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), _add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True) strict=True, arrow=True)
if boundary: 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): for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1)) _add(out, sentence, match.group(1), match.start(1), match.end(1))
return out return out
@@ -1036,7 +1101,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end()) end = masked.find('\n', match.end())
if end < 0: if end < 0:
end = len(masked) 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 continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]): if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue continue
@@ -985,3 +985,132 @@ EOF
assert_output --partial "INFO" assert_output --partial "INFO"
assert_output --partial "Upstream checks skipped for 'my-source' — no repo root above the skill directory" 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"
}
@@ -22,7 +22,10 @@ Checks performed:
0 source_keys present in agent pair but sources.md absent 0 source_keys present in agent pair but sources.md absent
1 FILL IN: placeholders in sources.md 1 FILL IN: placeholders in sources.md
2 source_keys in agent files → slug exists 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 4 Contributing files back-reference the parent slug in their source_keys
5 Research doc field present and not placeholder 5 Research doc field present and not placeholder
EOF EOF
@@ -244,14 +247,31 @@ has_fail = False
def emit_fail(desc, fpath, why, fix): def emit_fail(desc, fpath, why, fix):
global has_fail global has_fail
has_fail = True 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(): def print_findings():
for kind, desc, fpath, why, fix in findings: 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"FAIL {desc} — {fpath}")
print(f" Why: {why}") print(f" Why: {why}")
print(f" Fix: {fix}") print(f" Fix: {fix}")
print() print()
else:
print(f"INFO {desc} — {fpath}")
print(f" Note: {note}")
print()
# --- Collect source_keys from agent pair --- # --- Collect source_keys from agent pair ---
def get_source_keys_from_file(fpath): 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 --- # --- Checks 3, 4, 5: Per-slug checks in sources.md ---
for slug in parse_h2_slugs(sources_content): 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) 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: for cf_rel in cf_files:
cf_abs = os.path.join(plugin_root, cf_rel) cf_abs = os.path.join(plugin_root, cf_rel)
if not os.path.isfile(cf_abs): if not os.path.isfile(cf_abs):
@@ -264,6 +264,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): 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) base = os.path.basename(path)
if base.endswith('.agent.md'): if base.endswith('.agent.md'):
base = base[:-len('.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 # ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so. # 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 # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional # still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution. # `<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_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) 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_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 # 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 # `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, # 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), _add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True) strict=True, arrow=True)
if boundary: 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): for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1)) _add(out, sentence, match.group(1), match.start(1), match.end(1))
return out return out
@@ -1110,7 +1175,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end()) end = masked.find('\n', match.end())
if end < 0: if end < 0:
end = len(masked) 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 continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]): if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue continue
@@ -21,7 +21,10 @@ Checks performed:
3 source_keys in references/*.md → slug exists in sources.md (INFO if no 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 source_keys; an explicit 'source_keys: []' declares the file house-authored
and passes silently) 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 5 Contributing files back-reference the parent slug in their source_keys
6 Research doc field present and not placeholder 6 Research doc field present and not placeholder
7 Slug in sources.md present in upstream research doc (INFO only). A section 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 # 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 # `source_keys:` with nothing after it is NOT accepted here — that reads as a
# truncated or half-written entry, not a decision. # 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): def declares_empty_source_keys(fm):
"""True when frontmatter carries an explicit, empty `source_keys: []`.""" """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 research_docs_seen = {} # abs_path → set of slugs in sources.md that reference it
for slug in parse_h2_slugs(sources_content): 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) 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: for cf_rel in cf_files:
cf_abs = os.path.join(skill_dir, cf_rel) cf_abs = os.path.join(skill_dir, cf_rel)
if not os.path.isfile(cf_abs): if not os.path.isfile(cf_abs):
@@ -190,6 +190,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): 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) base = os.path.basename(path)
if base.endswith('.agent.md'): if base.endswith('.agent.md'):
base = base[:-len('.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 # ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so. # 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 # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional # still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution. # `<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_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) 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_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 # 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 # `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, # 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), _add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True) strict=True, arrow=True)
if boundary: 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): for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1)) _add(out, sentence, match.group(1), match.start(1), match.end(1))
return out return out
@@ -1036,7 +1101,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end()) end = masked.find('\n', match.end())
if end < 0: if end < 0:
end = len(masked) 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 continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]): if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue continue
+74 -1
View File
@@ -292,6 +292,16 @@ def _collect_package(pkg_dir, names):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): 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) base = os.path.basename(path)
if base.endswith('.agent.md'): if base.endswith('.agent.md'):
base = base[:-len('.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 # ambiguity to resolve, and an author who wants a route checked unconditionally
# has two ways to say so. # 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 # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs
# still resolve `gitea:gitea-prs`), so the patterns admit an optional # still resolve `gitea:gitea-prs`), so the patterns admit an optional
# `<plugin>:` prefix and normalize_target() strips it before resolution. # `<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_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) 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_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 # 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 # `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`, # 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), _add(out, sentence, match.group(1), match.start(1), match.end(1),
strict=True, arrow=True) strict=True, arrow=True)
if boundary: 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): for match in BACKTICK.finditer(sentence):
_add(out, sentence, match.group(1), match.start(1), match.end(1)) _add(out, sentence, match.group(1), match.start(1), match.end(1))
return out return out
@@ -1138,7 +1203,15 @@ def missing_reference_pointers(body, skill_dir):
end = masked.find('\n', match.end()) end = masked.find('\n', match.end())
if end < 0: if end < 0:
end = len(masked) 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 continue
if REFERENCE_QUALIFIER.search(masked[start:match.start()]): if REFERENCE_QUALIFIER.search(masked[start:match.start()]):
continue continue