Compare commits
6 Commits
4f4b55b0be
...
0f2bb242ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2bb242ad | |||
| a3e721e937 | |||
| 3811f5481b | |||
| 175ea89c0a | |||
| ed8c99efbd | |||
| a6eedacfd8 |
@@ -11,28 +11,28 @@
|
|||||||
{
|
{
|
||||||
"name": "kyberforge",
|
"name": "kyberforge",
|
||||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"category": "Developer Tools",
|
"category": "Developer Tools",
|
||||||
"source": "./plugins/kyberforge"
|
"source": "./plugins/kyberforge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"category": "Utilities",
|
"category": "Utilities",
|
||||||
"source": "./plugins/bin"
|
"source": "./plugins/bin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
||||||
"version": "1.3.6",
|
"version": "1.3.7",
|
||||||
"category": "Version Control",
|
"category": "Version Control",
|
||||||
"source": "./plugins/git"
|
"source": "./plugins/git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.",
|
"description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.",
|
||||||
"version": "1.3.7",
|
"version": "1.3.8",
|
||||||
"category": "Version Control",
|
"category": "Version Control",
|
||||||
"source": "./plugins/gitea"
|
"source": "./plugins/gitea"
|
||||||
},
|
},
|
||||||
|
|||||||
8
.github/plugin/marketplace.json
vendored
8
.github/plugin/marketplace.json
vendored
@@ -11,28 +11,28 @@
|
|||||||
{
|
{
|
||||||
"name": "kyberforge",
|
"name": "kyberforge",
|
||||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"category": "Developer Tools",
|
"category": "Developer Tools",
|
||||||
"source": "./plugins/kyberforge"
|
"source": "./plugins/kyberforge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"category": "Utilities",
|
"category": "Utilities",
|
||||||
"source": "./plugins/bin"
|
"source": "./plugins/bin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
||||||
"version": "1.3.6",
|
"version": "1.3.7",
|
||||||
"category": "Version Control",
|
"category": "Version Control",
|
||||||
"source": "./plugins/git"
|
"source": "./plugins/git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.",
|
"description": "Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.",
|
||||||
"version": "1.3.7",
|
"version": "1.3.8",
|
||||||
"category": "Version Control",
|
"category": "Version Control",
|
||||||
"source": "./plugins/gitea"
|
"source": "./plugins/gitea"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -254,27 +254,79 @@ repos:
|
|||||||
entry: bash
|
entry: bash
|
||||||
language: system
|
language: system
|
||||||
files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
|
files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$'
|
||||||
|
# Pinned by tests/test-skill-frontmatter.sh, which drives this exact
|
||||||
|
# `bash -c <script> <arg0> <files...>` call shape rather than a copy of
|
||||||
|
# the script -- the bug below was invisible to any test that did not.
|
||||||
args:
|
args:
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
|
# Every check reads the FRONTMATTER only, never the whole file. A
|
||||||
|
# `metadata:` / `name:` / `description:` line inside a body code
|
||||||
|
# fence is documentation (skill-author quotes exactly such a block)
|
||||||
|
# and used to satisfy these greps.
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
if [[ -f "$f" ]]; then
|
[[ -f "$f" ]] || continue
|
||||||
|
|
||||||
|
fm="$(awk '
|
||||||
|
{ sub(/\r$/, "") }
|
||||||
|
NR == 1 { sub(/^\357\273\277/, "") }
|
||||||
|
!opened && /^[[:blank:]]*$/ { next }
|
||||||
|
!opened {
|
||||||
|
if ($0 ~ /^---[[:blank:]]*$/) { opened = 1; next }
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
/^---[[:blank:]]*$/ { closed = 1; exit }
|
||||||
|
{ print }
|
||||||
|
END { if (!opened || !closed) exit 3 }
|
||||||
|
' "$f")" || {
|
||||||
|
echo "ERROR: $f has no closing YAML frontmatter block (expected --- ... --- at the top of the file)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
missing=""
|
missing=""
|
||||||
if ! grep -q "^name:" "$f"; then
|
printf '%s\n' "$fm" | grep -q "^name:" || missing="${missing}name: "
|
||||||
missing="${missing}name: "
|
printf '%s\n' "$fm" | grep -q "^description:" || missing="${missing}description: "
|
||||||
fi
|
|
||||||
if ! grep -q "^description:" "$f"; then
|
# Scoped to the `metadata:` block and stopped at the next
|
||||||
missing="${missing}description: "
|
# top-level key, so a `version:` under a following `source:` list
|
||||||
fi
|
# cannot stand in for it; the `^ version:` anchor is exact, so a
|
||||||
if ! grep -A10 "^metadata:" "$f" | grep -q " version:"; then
|
# deeper-nested ` version:` cannot either. No line budget, so a
|
||||||
missing="${missing}metadata.version "
|
# long `metadata:` block does not hide the key.
|
||||||
fi
|
ver="$(printf '%s\n' "$fm" | awk '
|
||||||
|
/^metadata:/ { inm = 1; next }
|
||||||
|
inm && /^[A-Za-z]/ { exit }
|
||||||
|
inm && /^ version:/ {
|
||||||
|
v = $0
|
||||||
|
sub(/^ version:[[:blank:]]*/, "", v)
|
||||||
|
sub(/[[:blank:]]+#.*$/, "", v)
|
||||||
|
sub(/[[:blank:]]+$/, "", v)
|
||||||
|
print "found:" v
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
')"
|
||||||
|
[[ -n "$ver" ]] || missing="${missing}metadata.version "
|
||||||
|
|
||||||
if [[ -n "$missing" ]]; then
|
if [[ -n "$missing" ]]; then
|
||||||
echo "ERROR: $f is missing required frontmatter fields (${missing})"
|
echo "ERROR: $f is missing required frontmatter fields (${missing})"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
raw="${ver#found:}"
|
||||||
|
v="$raw"
|
||||||
|
case "$v" in
|
||||||
|
\"*\") v="${v#\"}"; v="${v%\"}" ;;
|
||||||
|
\'*\') v="${v#\'}"; v="${v%\'}" ;;
|
||||||
|
esac
|
||||||
|
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "ERROR: $f has a malformed frontmatter metadata.version (${raw:-<empty>}) -- expected a three-part semver, e.g. \"1.0.0\""
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# arg0 for `bash -c`. WITHOUT it pre-commit's first filename lands in
|
||||||
|
# $0 and is dropped from "$@" -- so a single-file commit, the normal
|
||||||
|
# case, ran the loop zero times and reported Passed having checked
|
||||||
|
# nothing. Do not remove; tests/test-skill-frontmatter.sh pins it.
|
||||||
|
- skill-frontmatter
|
||||||
|
|
||||||
- id: skill-size-check
|
- id: skill-size-check
|
||||||
stages: ['pre-commit']
|
stages: ['pre-commit']
|
||||||
@@ -294,6 +346,21 @@ repos:
|
|||||||
# records for Vale warnings. Costs nothing on a clean file: the script
|
# records for Vale warnings. Costs nothing on a clean file: the script
|
||||||
# prints only findings.
|
# prints only findings.
|
||||||
|
|
||||||
|
- id: check-rtk-prefix
|
||||||
|
stages: ['pre-commit']
|
||||||
|
name: ADR-0023 rtk prefix on executable git commands
|
||||||
|
description: Enforce ADR-0023 clause 1 -- an executable, instructed git command in a shell code fence or a dispatch-table Run cell is written `rtk git`. Clauses 2 and 3 are not machine-decidable; a deliberately bare command opts out with the literal string ADR-0023 on its own line
|
||||||
|
entry: scripts/check-rtk-prefix.sh
|
||||||
|
language: script
|
||||||
|
files: '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$'
|
||||||
|
# README.md is excluded on purpose, not by oversight. A skill-directory
|
||||||
|
# README is consumer-facing prose that no agent ever loads, and the
|
||||||
|
# `git clone` lines in the seven tests/README.md files are setup
|
||||||
|
# instructions for a third party who has no rtk installed. Prefixing
|
||||||
|
# those would be actively wrong -- see ADR-0023's consumer section.
|
||||||
|
exclude: '(^|/)README\.md$'
|
||||||
|
pass_filenames: true
|
||||||
|
|
||||||
- id: vale-audit-prefilter-skill
|
- id: vale-audit-prefilter-skill
|
||||||
stages: ['pre-commit']
|
stages: ['pre-commit']
|
||||||
name: Vale audit prefilter (SKILL.md)
|
name: Vale audit prefilter (SKILL.md)
|
||||||
|
|||||||
16
LESSONS.md
16
LESSONS.md
@@ -60,29 +60,21 @@ write-eval's process requires presenting the full test plan and waiting for user
|
|||||||
|
|
||||||
The write-skill authoring standard required 8 body sections including Role and When/When not. These were assumed to be agentskills.io requirements. Checking the actual spec revealed the body has no format restrictions at all — recommended sections are step-by-step instructions, examples, and edge cases. Role and When/When not were added by convention without verifying the standard. Fix: before encoding any requirement as part of an authoring standard, check the upstream spec directly. The agentskills.io spec also confirmed that negative triggers belong in the description field — not in a separate body section — which eliminates a persistent duplication pattern across all skills.
|
The write-skill authoring standard required 8 body sections including Role and When/When not. These were assumed to be agentskills.io requirements. Checking the actual spec revealed the body has no format restrictions at all — recommended sections are step-by-step instructions, examples, and edge cases. Role and When/When not were added by convention without verifying the standard. Fix: before encoding any requirement as part of an authoring standard, check the upstream spec directly. The agentskills.io spec also confirmed that negative triggers belong in the description field — not in a separate body section — which eliminates a persistent duplication pattern across all skills.
|
||||||
|
|
||||||
## 2026-05-18 — Provenance fields in frontmatter are loaded on every skill scan
|
|
||||||
|
|
||||||
Fields like `source:`, `references:`, `version:`, `updated:`, and `when:` in SKILL.md frontmatter are loaded at agent startup alongside `name` and `description` for every installed skill. None of these are used for routing or runtime execution — they are audit and upgrade-cycle records. Loading them at startup violates progressive disclosure and wastes tokens proportional to the number of installed skills. Fix: move all non-routing frontmatter to a separate `META.md` file in the skill directory. Frontmatter keeps only `name`, `description`, `metadata.category`, and `allowed-tools` (when applicable) — the four fields the spec actually uses for routing and discovery.
|
|
||||||
|
|
||||||
## 2026-05-18 — Copy-fill is more deterministic than generate for structured skill artifacts
|
## 2026-05-18 — Copy-fill is more deterministic than generate for structured skill artifacts
|
||||||
|
|
||||||
When a skill produces a structured artifact like SKILL.md, the natural approach is to generate it from internalized rules in the Process section. But this means section structure is only as reliable as the agent's instruction-following under token pressure. Copy-fill (copy the template to the target path, then fill in content) separates structure from content: the template mechanically enforces section order and presence, freeing the Process section to focus only on sequencing constraints (what order to decide things) rather than also policing structure. Side benefit: the template is a human-usable artifact that can be adopted independently of the skill. Fix applied in write-skill refactor: SKILL-TEMPLATE.md and META-TEMPLATE.md are the authoritative structure sources; the Process section no longer contains a body structure constraint — the template handles it.
|
When a skill produces a structured artifact like SKILL.md, the natural approach is to generate it from internalized rules in the Process section. But this means section structure is only as reliable as the agent's instruction-following under token pressure. Copy-fill (copy the template to the target path, then fill in content) separates structure from content: the template mechanically enforces section order and presence, freeing the Process section to focus only on sequencing constraints (what order to decide things) rather than also policing structure. Side benefit: the template is a human-usable artifact that can be adopted independently of the skill. Fix applied in write-skill refactor: SKILL-TEMPLATE.md is the authoritative structure source; the Process section no longer contains a body structure constraint — the template handles it.
|
||||||
|
|
||||||
## 2026-05-17 — HITL gap: agent delegates confirmation to permission system
|
## 2026-05-17 — HITL gap: agent delegates confirmation to permission system
|
||||||
|
|
||||||
The agent-level HITL rule ("require explicit confirmation before irreversible shared-state operations") is being bypassed: the agent calls the tool and lets the permission dialog catch it. This means the rule is not firing in agent reasoning — it's the permission system acting as a safety net. If a user selects "don't ask again," the net disappears. Fix: the HITL rule needs to be framed as "do not call the tool" rather than "ask before proceeding" — the agent must ask first, then act only after explicit confirmation.
|
The agent-level HITL rule ("require explicit confirmation before irreversible shared-state operations") is being bypassed: the agent calls the tool and lets the permission dialog catch it. This means the rule is not firing in agent reasoning — it's the permission system acting as a safety net. If a user selects "don't ask again," the net disappears. Fix: the HITL rule needs to be framed as "do not call the tool" rather than "ask before proceeding" — the agent must ask first, then act only after explicit confirmation.
|
||||||
|
|
||||||
## 2026-05-26 — META-TEMPLATE uses YAML comments; META.md output retains them
|
|
||||||
|
|
||||||
META-TEMPLATE.md uses YAML `#` comments to explain fields inline. SKILL-TEMPLATE.md uses HTML comments inside XML tags, which the agent strips on fill. The structural difference means SKILL.md output is clean but META.md output retains the explanatory `#` lines — an inconsistency. Fix (deferred): restructure META-TEMPLATE.md so all explanatory guidance is prose above the code block (markdown, never copied into the output YAML), and the code block itself uses `<placeholder>` syntax with no `#` comment lines. This makes META.md fill behaviour deterministic for the same reason SKILL.md fill is: `<...>` markers are unambiguously replaceable; prose above the block is not part of the template. Do not apply until the human/copy-fill tradeoff is resolved — see 2026-05-26 session discussion.
|
|
||||||
|
|
||||||
## 2026-05-26 — Overlap checks must scan the deployed directory, not just the source repo
|
## 2026-05-26 — Overlap checks must scan the deployed directory, not just the source repo
|
||||||
|
|
||||||
`write-a-skill` existed only in `~/.agents/skills/` (installed from a pre-refactor source) and was invisible during a repo-level scan of `.agents/skills/`. Governance reviews and overlap checks that only look at the source repo will miss skills added by install.sh from other sources or prior runs. Fix: overlap checks must scan the deployed `~/.agents/skills/` directory, not just the repo's `.agents/skills/`.
|
`write-a-skill` existed only in `~/.agents/skills/` (installed from a pre-refactor source) and was invisible during a repo-level scan of `.agents/skills/`. Governance reviews and overlap checks that only look at the source repo will miss skills added by install.sh from other sources or prior runs. Fix: overlap checks must scan the deployed `~/.agents/skills/` directory, not just the repo's `.agents/skills/`.
|
||||||
|
|
||||||
## 2026-05-26 — `model:` field belongs in SKILL.md frontmatter, not META.md
|
## 2026-05-26 — `model:` field belongs in SKILL.md frontmatter, not a sidecar file
|
||||||
|
|
||||||
Claude Code supports `model:` as a provider extension in SKILL.md frontmatter — it overrides the session model for the skill's turn and reverts after. Attempting to put it in META.md was wrong: META.md is provenance/audit metadata, not runtime config. The boundary: if a field affects agent behaviour at invocation time, it belongs in SKILL.md frontmatter; if it serves upgrade reviews and audit trails, it belongs in META.md.
|
Claude Code supports `model:` as a provider extension in SKILL.md frontmatter — it overrides the session model for the skill's turn and reverts after. Attempting to move it out to a provenance sidecar was wrong: a sidecar is audit metadata, not runtime config. The boundary: if a field affects agent behaviour at invocation time, it belongs in SKILL.md frontmatter.
|
||||||
|
|
||||||
## 2026-05-26 — Research agents present synthesis as spec fact
|
## 2026-05-26 — Research agents present synthesis as spec fact
|
||||||
|
|
||||||
@@ -126,7 +118,7 @@ Two forks independently fixed `references/sources.md` with different approaches
|
|||||||
|
|
||||||
## 2026-06-28 — Implementation agents must invoke /skill-author, not write skill files directly
|
## 2026-06-28 — Implementation agents must invoke /skill-author, not write skill files directly
|
||||||
|
|
||||||
When briefing an agent to implement a new skill, the instinct is to tell it to write the SKILL.md and supporting files directly. This bypasses Step 5 of the skill-author process (provenance), which requires reading all research `sources.md` files and recording every `extracted` slug in META.md. The `validate-provenance.sh` script catches the gap — but only after the commit, requiring a fix round. This pattern recurred twice in one session (plugin-author and marketplace-author initial implementation, then again in the first round of fix agents). Fix: briefs for implementation agents must explicitly say "invoke `/skill-author` (read and follow `plugins/kyberforge/.apm/skills/skill-author/SKILL.md`)" — not "write the skill files." Invoking the skill is the only reliable way to ensure all process gates, including provenance, run.
|
When briefing an agent to implement a new skill, the instinct is to tell it to write the SKILL.md and supporting files directly. This bypasses Step 5 of the skill-author process (provenance), which requires reading all research `sources.md` files and recording every `extracted` slug in the skill's own `references/sources.md`. The `validate-provenance.sh` script catches the gap — but only after the commit, requiring a fix round. This pattern recurred twice in one session (plugin-author and marketplace-author initial implementation, then again in the first round of fix agents). Fix: briefs for implementation agents must explicitly say "invoke `/skill-author` (read and follow `plugins/kyberforge/.apm/skills/skill-author/SKILL.md`)" — not "write the skill files." Invoking the skill is the only reliable way to ensure all process gates, including provenance, run.
|
||||||
|
|
||||||
## 2026-07-05 — Repo root is a bare checkout; work happens in worktrees only
|
## 2026-07-05 — Repo root is a bare checkout; work happens in worktrees only
|
||||||
|
|
||||||
|
|||||||
10
apm.yml
10
apm.yml
@@ -42,7 +42,7 @@ dependencies:
|
|||||||
# after a kyberforge release, check this first.
|
# after a kyberforge release, check this first.
|
||||||
executables:
|
executables:
|
||||||
allow:
|
allow:
|
||||||
kyberforge#1.6.1:
|
kyberforge#1.6.2:
|
||||||
hooks: true
|
hooks: true
|
||||||
bin: true
|
bin: true
|
||||||
|
|
||||||
@@ -79,25 +79,25 @@ marketplace:
|
|||||||
- name: kyberforge
|
- name: kyberforge
|
||||||
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
||||||
source: ./plugins/kyberforge
|
source: ./plugins/kyberforge
|
||||||
version: 1.6.1
|
version: 1.6.2
|
||||||
category: Developer Tools
|
category: Developer Tools
|
||||||
|
|
||||||
- name: bin
|
- name: bin
|
||||||
description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.
|
description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.
|
||||||
source: ./plugins/bin
|
source: ./plugins/bin
|
||||||
version: 1.1.6
|
version: 1.1.7
|
||||||
category: Utilities
|
category: Utilities
|
||||||
|
|
||||||
- name: git
|
- name: git
|
||||||
description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.
|
description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.
|
||||||
source: ./plugins/git
|
source: ./plugins/git
|
||||||
version: 1.3.6
|
version: 1.3.7
|
||||||
category: Version Control
|
category: Version Control
|
||||||
|
|
||||||
- name: gitea
|
- name: gitea
|
||||||
description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.
|
description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.
|
||||||
source: ./plugins/gitea
|
source: ./plugins/gitea
|
||||||
version: 1.3.7
|
version: 1.3.8
|
||||||
category: Version Control
|
category: Version Control
|
||||||
|
|
||||||
- name: core
|
- name: core
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ per-plugin choice.
|
|||||||
- **The one outlier in the other direction, `git-commits`, keeps its existing value** (`0.1.3`) —
|
- **The one outlier in the other direction, `git-commits`, keeps its existing value** (`0.1.3`) —
|
||||||
it already had real tracked history under the old conditional rule, and this decision does not
|
it already had real tracked history under the old conditional rule, and this decision does not
|
||||||
reset skills that were already compliant.
|
reset skills that were already compliant.
|
||||||
|
- **`bin/write-docs`'s top-level `version:` moves into `metadata:`, normalized to `1.0.0`.** It is
|
||||||
|
the one skill that carried a version outside the `metadata:` block, which is why the table above
|
||||||
|
counts `bin` as 0 — a top-level `version:` is not `metadata.version`, and nothing reads it. #127
|
||||||
|
raised it alongside the split because "does a skill carry a version" and "where does it live" are
|
||||||
|
the same question. Its value (`1.0`) is not semver and carries no more real history than the 27
|
||||||
|
unversioned skills, so it is relocated and reset to the same `1.0.0` seed rather than preserved
|
||||||
|
like `git-commits`'s tracked `0.1.3`.
|
||||||
- **`skill-frontmatter`'s pre-commit hook gains the check.** It already fails a SKILL.md missing
|
- **`skill-frontmatter`'s pre-commit hook gains the check.** It already fails a SKILL.md missing
|
||||||
`name:` or `description:`; a missing `metadata.version` is now the same class of failure, not a
|
`name:` or `description:`; a missing `metadata.version` is now the same class of failure, not a
|
||||||
style nit an audit might or might not catch.
|
style nit an audit might or might not catch.
|
||||||
@@ -62,9 +69,10 @@ it discards real revision signal for no gain.
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
27 SKILL.md files gain `metadata.version: "1.0.0"`. `skill-author`'s `create.md` moves the field from
|
27 SKILL.md files gain `metadata.version: "1.0.0"`, and a 28th — `bin/write-docs` — reaches the same
|
||||||
"Optional frontmatter" to the required list, citing this ADR. `skill-author`'s own SKILL.md drops the
|
value by relocating its top-level `version: "1.0"` into `metadata:`. `skill-author`'s `create.md`
|
||||||
"with `metadata.version` present" conditional in its bump-rule line, since presence is no longer in
|
moves the field from "Optional frontmatter" to the required list, citing this ADR. `skill-author`'s
|
||||||
question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended to require the field,
|
own SKILL.md drops the "with `metadata.version` present" conditional in its bump-rule line, since
|
||||||
closing the gap #113 and #118 both named in the same audit pass: a stated rule with nothing enforcing
|
presence is no longer in question. `.pre-commit-config.yaml`'s `skill-frontmatter` hook is extended
|
||||||
it drifts the same way an unstated one does.
|
to require the field, closing the gap #113 and #118 both named in the same audit pass: a stated rule
|
||||||
|
with nothing enforcing it drifts the same way an unstated one does.
|
||||||
|
|||||||
168
docs/adr/0023-rtk-prefix-marks-executable-commands-only.md
Normal file
168
docs/adr/0023-rtk-prefix-marks-executable-commands-only.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# The `rtk` prefix marks executable commands only, and is repo-wide
|
||||||
|
|
||||||
|
**Status: accepted (2026-09-08).**
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`CLAUDE.md` states the org convention as a golden rule: "Always prefix commands with `rtk`. If RTK
|
||||||
|
has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always
|
||||||
|
safe to use." Issue #113 observed that the rule had never been written down for skill *prose*, where
|
||||||
|
a `git <subcommand>` mention can be either an instruction to execute or a reference to the concept,
|
||||||
|
and that the corpus had drifted into carrying both spellings with no stated rule. PR #130 swept the
|
||||||
|
`git` plugin and recorded a two-way split in `plugins/git/README.md`.
|
||||||
|
|
||||||
|
Review found two defects in that sweep, and both are in the premise rather than the execution.
|
||||||
|
|
||||||
|
**RTK is not output-transparent.** `rtk git --help` enumerates twelve filtered subcommands — `diff`,
|
||||||
|
`log`, `status`, `show`, `add`, `commit`, `push`, `pull`, `branch`, `fetch`, `stash`, `worktree`.
|
||||||
|
Everything else is a true passthrough. Inside that set the filter is not a formatting preference; it
|
||||||
|
changes what the command *reports*. Measured against rtk 0.42.4:
|
||||||
|
|
||||||
|
| Command | What rtk does to it |
|
||||||
|
|---|---|
|
||||||
|
| `worktree list --porcelain -z` | discards both flags; no NUL separators, no `locked`/`lock_reason` field at all |
|
||||||
|
| `worktree list -v` | abbreviates `/root/…` to `~/…`, collapses column alignment |
|
||||||
|
| `branch --list <name>` | emits a phantom `* ` line even on no match |
|
||||||
|
| `diff --name-only` / `--name-status` | appends a blank line and a `Changes:` trailer |
|
||||||
|
| `diff --word-diff[=color\|=porcelain]` | emits no `[-removed-] {+added+}` markers; substitutes a diffstat |
|
||||||
|
| `log -L` | truncates each diff body line at ~72 characters with an ellipsis |
|
||||||
|
| `stash pop` (on conflict) | prints only `FAILED: git stash pop`, swallowing `CONFLICT`, `Unmerged paths` and the retained-entry notice |
|
||||||
|
| `stash list` (empty) | prints `No stashes` where git prints nothing |
|
||||||
|
|
||||||
|
Every one of those falsified a skill that was written against the bare output. `git-worktrees`'s
|
||||||
|
Step 2 required `locked` and `lock_reason` from a command whose rtk rendering has never carried
|
||||||
|
them; `git-log-format.md` documented `[-removed-] {+added+}` markers beside a command that no longer
|
||||||
|
produces them. The two-way split could not see any of this, because both halves of it are about what
|
||||||
|
a *sentence* is doing and none of it is about what the *command* does.
|
||||||
|
|
||||||
|
**The rule is not `git`-plugin-scoped.** `plugins/git/README.md` claimed the `gitea-*` skills
|
||||||
|
"contain no `git`/`rtk` mentions at all". Five `gitea-*` SKILL.md files run `git remote get-url
|
||||||
|
origin` in a fenced ```bash Step block — the README's own canonical example of "executable,
|
||||||
|
instructed" — plus `git branch --show-current` in a reference file and three `git remote -v` in
|
||||||
|
`gitea-orchestrate.agent.md`. A convention stated inside one plugin's README is invisible from the
|
||||||
|
plugin next door, which is how those eight sites stayed bare through the sweep that existed to find
|
||||||
|
them.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One rule, three clauses, repo-wide** — every `plugins/*/.apm/skills/**` and
|
||||||
|
`plugins/*/.apm/agents/**` file, not the `git` plugin alone.
|
||||||
|
|
||||||
|
1. **Executable and instructed → `rtk git`.** Anything telling the agent to run a command now: an
|
||||||
|
imperative step, a dispatch-table "Run" cell, a fenced code-block procedure.
|
||||||
|
`rtk git push -u origin <branch>`.
|
||||||
|
2. **Illustrative or referential → bare `git`.** Naming a flag's behaviour, quoting a doc's own
|
||||||
|
heading, describing a command in the abstract, warning against an anti-pattern. "`git switch`
|
||||||
|
refuses rather than clobbering conflicting local edits."
|
||||||
|
3. **Machine-parsed or interactive → bare `git`, and say why inline.** A command whose output the
|
||||||
|
skill parses, where rtk is in the filtered set above; or a command that hands control to an
|
||||||
|
interactive child process.
|
||||||
|
|
||||||
|
Clause 3 is the new one and it looks arbitrary without the table in Context, which is why the
|
||||||
|
measurements are recorded here rather than left in a PR thread. It is applied per subcommand and per
|
||||||
|
flag, not per skill: `tag --list` stays prefixed because rtk passes it through byte-identically,
|
||||||
|
while `branch --list` two words away goes bare because it does not. `git remote get-url origin`,
|
||||||
|
`git remote -v`, `git branch --show-current`, `git log --oneline -1` and `git add -u` were all
|
||||||
|
re-measured as byte-identical passthroughs and are therefore prefixed, parsing notwithstanding.
|
||||||
|
|
||||||
|
Two consequences of that per-subcommand basis are worth stating, because both are load-bearing and
|
||||||
|
neither is comfortable:
|
||||||
|
|
||||||
|
- **rtk's filtered set is a moving target.** `git rebase` and `git mergetool` are passthroughs on
|
||||||
|
0.42.4 — verified under `script(1)`, both inherit a real TTY, contradicting an earlier report that
|
||||||
|
they did not. They stay bare anyway, on the interactive limb: a token filter has nothing to offer a
|
||||||
|
command that hands control to an editor, and the prefix would only buy exposure to whatever a later
|
||||||
|
rtk version decides to do with those subcommands. The same reasoning makes the *inner* call in
|
||||||
|
`` `rtk git remote add origin-push $(git config remote.origin.url)` `` bare while the outer stays
|
||||||
|
prefixed — `config` passes through cleanly today, but its stdout becomes a remote URL that is then
|
||||||
|
force-pushed to, and that is not a blast radius to lend to a future filter change.
|
||||||
|
- **`branch --show-current` sits on the sharp edge.** It is in the filtered set, it is parsed, and it
|
||||||
|
is prefixed — on a measurement, in a subcommand whose sibling `--list` is exactly the defect clause
|
||||||
|
3 exists for. If rtk's `branch` filter is ever extended, that is the first site to break. It is
|
||||||
|
called out rather than hedged, because a rule whose exceptions are unrecorded is the state this ADR
|
||||||
|
is replacing.
|
||||||
|
|
||||||
|
**A clause-3 site says so inline, in a few words.** "bare, not `rtk`: rtk prints a phantom `* ` line
|
||||||
|
even on no match". Without it the next sweep re-prefixes the command, which is how #113 recurs.
|
||||||
|
|
||||||
|
**The rule lives here, and `docs/spec/gates.md` carries the gate.** `plugins/git/README.md` is
|
||||||
|
reduced to a pointer. It had also cited `git-workflow/references/hard-rules.md` as a place the rule
|
||||||
|
was written down; that file contains no occurrence of "rtk", and the citation is removed rather than
|
||||||
|
repaired.
|
||||||
|
|
||||||
|
**Clause 1 is enforced by a `check-rtk-prefix` pre-commit hook; clauses 2 and 3 are not enforceable
|
||||||
|
and are not gated.** The hook checks the two places a `git` mention is unambiguously an instruction —
|
||||||
|
a line in a shell-tagged code fence, and the opening backticked span of a "Run" column cell — and a
|
||||||
|
deliberately-bare command opts out with the literal string `ADR-0023` on its own line. Its coverage
|
||||||
|
limits are recorded in `docs/spec/gates.md`, not smoothed over.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
**Add `compatibility:` frontmatter to every skill.** These six plugins are installable by third
|
||||||
|
parties, and a consumer who installs `git` from the marketplace has no `rtk` on their PATH. Every
|
||||||
|
prefixed command in the corpus is a plain `git` invocation with a word in front of it, so the prefix
|
||||||
|
is *droppable*: delete `rtk ` and the command is correct. A `compatibility:` line per skill would
|
||||||
|
state that in a machine-readable field. Rejected on cost. It is 39 lines of frontmatter restating one
|
||||||
|
sentence, it is preloaded into every agent's context every session under ADR-0020's budget — the
|
||||||
|
field is not free the way a line in a doc is — and it has no consumer: nothing reads
|
||||||
|
`compatibility:`, so the field would be a comment with a colon in it. The consumer situation is
|
||||||
|
documented here and in `plugins/git/README.md` instead, which is where a human installing a plugin
|
||||||
|
actually looks. The same two-line note is owed to the other five plugin READMEs and is not yet
|
||||||
|
written.
|
||||||
|
|
||||||
|
**Move rtk to the execution layer entirely.** Skills instruct bare `git` throughout; `CLAUDE.md`'s
|
||||||
|
session rule handles prefixing at the point of execution. This is the strongest rejected option and
|
||||||
|
it deserves the space: it closes the consumer gap and all eight output defects at once, because the
|
||||||
|
executing agent knows what it is about to parse and the skill does not have to predict it. It also
|
||||||
|
removes clause 3 entirely — there is nothing to except. Rejected because the prefix is lost wherever
|
||||||
|
an agent copies a command literally, which is the common case for a fenced procedure block and the
|
||||||
|
whole reason dispatch tables exist. The org convention's value is that the prefix is *already there*
|
||||||
|
in the text the agent lifts; a rule that relies on the agent remembering to add it is the rule that
|
||||||
|
produced the drift in the first place. Worth revisiting if rtk ever ships a shell shim, which would
|
||||||
|
make the execution layer transparent and this trade different.
|
||||||
|
|
||||||
|
**Keep the two-way split and fix the eight sites by hand.** Rejected: the split has no vocabulary for
|
||||||
|
"this command is executable, instructed, and must still be bare", so the eight sites would be
|
||||||
|
unexplained exceptions and the next sweep re-prefixes them. That is the failure this ADR exists to
|
||||||
|
stop, not a smaller version of it.
|
||||||
|
|
||||||
|
**Gate clauses 2 and 3 as well.** Rejected as undecidable. "Run `git switch <branch>`" and "`git
|
||||||
|
switch` refuses rather than clobbering local edits" are the same token sequence; separating them is a
|
||||||
|
judgement about what a sentence is doing. A gate that guessed would fire on correct content, and a
|
||||||
|
gate that fires on correct content gets added to `SKIP`, which disarms clause 1 along with it.
|
||||||
|
|
||||||
|
## The boundary the rule does not decide
|
||||||
|
|
||||||
|
Two shapes in the corpus resisted the two-way split. The three-clause rule resolves one and does not
|
||||||
|
resolve the other; both are recorded so an author meeting a third one knows which kind it is.
|
||||||
|
|
||||||
|
**`git-worktrees/SKILL.md`'s tracking row carries both spellings in one Run cell** — `rtk git
|
||||||
|
worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add
|
||||||
|
<path> <branch>` expands to exactly this. **Resolved: the clauses apply per mention, not per row,
|
||||||
|
per cell or per file.** The first is the instruction (clause 1), the second names what the first
|
||||||
|
expands to (clause 2), and one table cell can hold one of each. The rule needed no change; the
|
||||||
|
*gate* did, and it checks only a Run cell's opening span for exactly this reason.
|
||||||
|
|
||||||
|
**`git-submodules/references/setup-and-update.md:80` has a git command inside a quoted argument to
|
||||||
|
another command** — `rtk git submodule foreach 'git pull origin main || :'`. **Not resolved: all
|
||||||
|
three clauses describe a command the reading agent executes, and the inner `git pull` is not one.**
|
||||||
|
It is the literal text of an argument that `git submodule foreach` hands to a subshell running inside
|
||||||
|
each submodule's own working tree, where the local convention does not reach. The file already gets
|
||||||
|
this right and already justifies it in prose two lines below ("the git calls in it are the
|
||||||
|
submodule's own — that is the one place a bare `git` is correct"). **An author meeting this shape
|
||||||
|
should do the same: leave the inner command bare and justify it inline.** It is deliberately not
|
||||||
|
promoted to a fourth clause on one instance. The gate does not decide it either — it happens to pass
|
||||||
|
this line, because the segment containing the inner command begins with `rtk`, and that is an
|
||||||
|
accident of the split rather than an understanding of quoting.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Eleven sites in `plugins/git/.apm/skills/**` revert to bare `git` under clause 3, each carrying a
|
||||||
|
short inline reason. Eight sites across `plugins/gitea/.apm/skills/**` and
|
||||||
|
`plugins/gitea/.apm/agents/gitea-orchestrate.agent.md` gain the prefix under clause 1, and one in
|
||||||
|
`pc-run/SKILL.md` that the #130 sweep's grep missed because the backtick opens with `SKIP=` rather
|
||||||
|
than `git `. `plugins/git/README.md`'s Conventions section becomes a pointer here, minus a paragraph
|
||||||
|
that was false about the `gitea-*` skills and a citation to a file that does not carry the rule.
|
||||||
|
A `check-rtk-prefix` pre-commit hook and `tests/test-check-rtk-prefix.sh` land with it; the test runs
|
||||||
|
the gate against the pre-sweep corpus on `main` and asserts it fails there, because a gate that only
|
||||||
|
passes on the fixed tree proves nothing about the drift it was written for.
|
||||||
@@ -53,8 +53,7 @@ All skills — new and rebuilt — must follow this standard:
|
|||||||
- `name:` — matches directory name
|
- `name:` — matches directory name
|
||||||
- `description:` — trigger-tested before writing the body (explicit, implicit, negative cases)
|
- `description:` — trigger-tested before writing the body (explicit, implicit, negative cases)
|
||||||
- `metadata: category:` — from the category table above
|
- `metadata: category:` — from the category table above
|
||||||
|
- `metadata: version:` — mandatory for every skill (ADR-0022)
|
||||||
`version:`, `updated:`, `when:`, `source:`, and `references:` are provenance/audit fields — they live in `META.md` alongside the SKILL.md (not in frontmatter). See `META-TEMPLATE.md` in `.agents/skills/write-skill/` for the META.md schema.
|
|
||||||
|
|
||||||
**Body required sections:**
|
**Body required sections:**
|
||||||
- Constraints (highest-ROI element — prevents overengineering)
|
- Constraints (highest-ROI element — prevents overengineering)
|
||||||
|
|||||||
@@ -103,21 +103,21 @@ Do not write the SKILL.md until the human has confirmed every section. The synth
|
|||||||
**c. SKILL.md** (sub-agent)
|
**c. SKILL.md** (sub-agent)
|
||||||
Once all sections are confirmed, spawn a write agent to produce the SKILL.md using `write-skill` (or hand-write for bootstrap skills). The agent receives: trigger description, per-section decisions from step b, upstream content to incorporate, authoring standard (see below).
|
Once all sections are confirmed, spawn a write agent to produce the SKILL.md using `write-skill` (or hand-write for bootstrap skills). The agent receives: trigger description, per-section decisions from step b, upstream content to incorporate, authoring standard (see below).
|
||||||
|
|
||||||
**c. META.md — `source:` and `references:` fields**
|
**d. Provenance — source and reference records**
|
||||||
Populate `META.md` after upstream review. Two distinct fields:
|
Record provenance after upstream review. Two distinct kinds:
|
||||||
- `source:` — upstream provenance tracking (repo slug, commit SHA, files adopted with inline comments, updated date). Present only if content was adopted. Absence = self-authored.
|
- Upstream provenance (repo slug, commit SHA, files adopted with inline comments, updated date). Present only if content was adopted. Absence = self-authored.
|
||||||
- `references:` — general citations (research papers, documentation, standard specifications). Present only if the skill cites external research.
|
- General citations (research papers, documentation, standard specifications). Present only if the skill cites external research.
|
||||||
|
|
||||||
Both fields live in `META.md` alongside the SKILL.md — not in frontmatter. See `META-TEMPLATE.md` in `.agents/skills/write-skill/` for the full schema.
|
Both are recorded in the skill's own `references/sources.md`, keyed by the `source_keys:` its SKILL.md and reference files declare. `validate-provenance.sh` checks that chain.
|
||||||
|
|
||||||
**d. eval.yaml** (sub-agent)
|
**e. eval.yaml** (sub-agent)
|
||||||
Invoke `write-eval` in two steps to preserve its confirmation gate:
|
Invoke `write-eval` in two steps to preserve its confirmation gate:
|
||||||
1. Sub-agent proposes test cases and returns the plan to the main conversation.
|
1. Sub-agent proposes test cases and returns the plan to the main conversation.
|
||||||
2. Human confirms the plan; then sub-agent writes the file.
|
2. Human confirms the plan; then sub-agent writes the file.
|
||||||
|
|
||||||
Do not pass pre-designed test cases directly to a write agent — that collapses the plan-then-confirm gate into a single step, bypassing write-eval's own constraint. Co-located at `.agents/evals/<category>/<skill-name>/eval.yaml`. Must contain all five required test types (see Eval schema below).
|
Do not pass pre-designed test cases directly to a write agent — that collapses the plan-then-confirm gate into a single step, bypassing write-eval's own constraint. Co-located at `.agents/evals/<category>/<skill-name>/eval.yaml`. Must contain all five required test types (see Eval schema below).
|
||||||
|
|
||||||
**e. HITL behavioral test**
|
**f. HITL behavioral test**
|
||||||
Human opens a fresh Claude session, invokes the skill with its trigger phrase, and verifies output. Do not batch more than 2–3 skills before running behavioral tests — output volume must stay within genuine human review capacity. An approval that cannot be meaningfully evaluated is not an approval.
|
Human opens a fresh Claude session, invokes the skill with its trigger phrase, and verifies output. Do not batch more than 2–3 skills before running behavioral tests — output volume must stay within genuine human review capacity. An approval that cannot be meaningfully evaluated is not an approval.
|
||||||
|
|
||||||
### Step 6 — Session handoff
|
### Step 6 — Session handoff
|
||||||
@@ -157,12 +157,11 @@ name: skill-name
|
|||||||
description: <trigger description — routing only; written and tested first; max 1024 chars>
|
description: <trigger description — routing only; written and tested first; max 1024 chars>
|
||||||
metadata:
|
metadata:
|
||||||
category: <design|factory|implement|test|review|deploy|operate|cross-cutting|iac>
|
category: <design|factory|implement|test|review|deploy|operate|cross-cutting|iac>
|
||||||
|
version: <semver — mandatory for every skill; see ADR-0022>
|
||||||
# allowed-tools: <add only when the skill has a narrow, well-defined tool surface; omit otherwise>
|
# allowed-tools: <add only when the skill has a narrow, well-defined tool surface; omit otherwise>
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontmatter contains only these fields. `version`, `updated`, `when`, `source`, and `references` are provenance/audit fields — they are not used for routing or runtime execution. They live in `META.md` alongside the SKILL.md, loaded only when needed. See `META-TEMPLATE.md` in `.agents/skills/write-skill/` for the META.md schema.
|
|
||||||
|
|
||||||
### Body sections
|
### Body sections
|
||||||
|
|
||||||
Use `.agents/skills/write-skill/SKILL-TEMPLATE.md` as the authoritative structure reference. The template defines the required sections, correct order, XML grouping, and placeholder comments for each section.
|
Use `.agents/skills/write-skill/SKILL-TEMPLATE.md` as the authoritative structure reference. The template defines the required sections, correct order, XML grouping, and placeholder comments for each section.
|
||||||
@@ -230,6 +229,6 @@ Upstream review happens per-skill during step 2, not once at chunk start.
|
|||||||
|
|
||||||
## Open decisions carried forward
|
## Open decisions carried forward
|
||||||
|
|
||||||
- **Bidirectional reference convention** — Chunk 4 (reference scanner tooling; reverse map "what files point to X?"). The `when:` field itself is resolved — it lives in `META.md` alongside every skill.
|
- **Bidirectional reference convention** — Chunk 4 (reference scanner tooling; reverse map "what files point to X?").
|
||||||
- **PRD/issue template scope** — refined during `write-prd` (0020) and `write-issue-spec` (0019) implementation
|
- **PRD/issue template scope** — refined during `write-prd` (0020) and `write-issue-spec` (0019) implementation
|
||||||
- **Merging `zoom-out` into architect role** — revisit at Chunk 5 grill
|
- **Merging `zoom-out` into architect role** — revisit at Chunk 5 grill
|
||||||
|
|||||||
@@ -128,12 +128,43 @@ not an authoring change.
|
|||||||
### `skill-frontmatter`, the other hook on that scope
|
### `skill-frontmatter`, the other hook on that scope
|
||||||
|
|
||||||
A second `repo: local` pre-commit hook, `skill-frontmatter`, runs on the **same** `files:` pattern at
|
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
|
the same stage. It is a shell loop that, **for the YAML frontmatter block only** — everything between
|
||||||
`grep -q "^description:"`, failing with "missing required frontmatter fields" if either is absent.
|
the opening `---` and the next `---` — asserts four things per file:
|
||||||
|
|
||||||
**It overlaps ADR-0020's "description present and non-empty" FAIL, and the overlap is not clean.**
|
| Check | Rejects with |
|
||||||
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:
|
| a `^name:` line is present | "missing required frontmatter fields (name: …)" |
|
||||||
|
| a `^description:` line is present | "missing required frontmatter fields (description: …)" |
|
||||||
|
| `metadata:` contains a `^ version:` key, anchored, scanning to the next top-level key | "missing required frontmatter fields (metadata.version)" |
|
||||||
|
| that version's value is three-part semver (`1.0.0`, quoted or not) | "has a malformed frontmatter metadata.version (…)" |
|
||||||
|
|
||||||
|
Every one of those qualifiers is load-bearing, and each replaced a defect that let the hook report
|
||||||
|
Passed having measured nothing. `tests/test-skill-frontmatter.sh` pins all of them:
|
||||||
|
|
||||||
|
- **Frontmatter-scoped, not whole-file.** The checks used to `grep` the entire file, so a `metadata:`
|
||||||
|
or `name:` block quoted in a **body code fence** satisfied them — `skill-author`'s own docs quote
|
||||||
|
exactly such a block.
|
||||||
|
- **Bounded by the next top-level key, not by `-A10`.** The version check was
|
||||||
|
`grep -A10 "^metadata:" | grep -q " version:"`, which ran ten lines past the end of the block: a
|
||||||
|
`version:` belonging to a following `source:` list entry counted (`write-docs` and `research` both
|
||||||
|
have a `source:` list immediately after `metadata:`), while a `metadata:` block with more than ten
|
||||||
|
lines before its `version:` was reported missing.
|
||||||
|
- **`^ version:` anchored.** `" version:"` was an unanchored substring, so a deeper-nested
|
||||||
|
` version:` matched too.
|
||||||
|
- **The value is asserted, not just the key.** `plugins/bin/.apm/skills/write-docs/SKILL.md` carried
|
||||||
|
`version: "1.0"` — present, correctly nested, and not a version — through an entire PR under a
|
||||||
|
presence-only check. Two-part `1.0` is a YAML float, not a version string.
|
||||||
|
- **The call shape is pinned.** `entry: bash` with `args: ['-c', <script>, …]` needs an explicit
|
||||||
|
arg0 placeholder after the script: without it `bash -c` puts pre-commit's **first** filename in
|
||||||
|
`$0`, where `for f in "$@"` never sees it. A single-file commit — the normal case — therefore ran
|
||||||
|
the loop body zero times and exited 0. The third `args` entry (`skill-frontmatter`) exists solely
|
||||||
|
to absorb `$0`; do not remove it.
|
||||||
|
- **An unreadable file is an error, not a pass.** A file with no closing `---` fails with "no closing
|
||||||
|
YAML frontmatter block" rather than falling through to a green.
|
||||||
|
|
||||||
|
**It still 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` |
|
| Frontmatter | `skill-frontmatter` | `skill-size-check` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -145,10 +176,34 @@ against, and it is the only one of the two that objects to a quoted key. Neither
|
|||||||
currently live in the corpus, and the honest reading is that presence is `skill-size-check`'s
|
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.
|
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
|
What the hook adds that **no** ADR-0020 check reads is two keys: `name:` and `metadata.version`. A
|
||||||
`name:` passes `skill-size-check` at exit 0. That is its real and only unique coverage, and the
|
`SKILL.md` missing either passes `skill-size-check` at exit 0. That is its unique coverage, and the
|
||||||
reason not to fold it into the size gate on the grounds of redundancy.
|
reason not to fold it into the size gate on the grounds of redundancy.
|
||||||
|
|
||||||
|
#### Why this one stays a shell parser
|
||||||
|
|
||||||
|
[`python3` and PyYAML are hard requirements](#python3-and-pyyaml-are-hard-requirements) below records
|
||||||
|
that a hand-rolled frontmatter reader on this exact `files:` scope was **deliberately deleted**,
|
||||||
|
because "a reader that mis-parses an unfamiliar scalar shape reports a clean pass on a file it never
|
||||||
|
measured." That reasoning is about `skill-size-check` and does **not** transfer here. Do not delete
|
||||||
|
this hook citing it. Three differences:
|
||||||
|
|
||||||
|
1. **It answers a strictly narrower question.** `skill-size-check` must know the *folded value* of a
|
||||||
|
`>`-block scalar to count its characters, which is where a line reader diverges from a parser —
|
||||||
|
one corpus description measured 270 characters parsed and 412 unparsed. This hook asks only
|
||||||
|
whether a key is on a line and whether one short **plain scalar** matches `N.N.N`. There is no
|
||||||
|
folding, no multi-line value, and no measurement to get subtly wrong.
|
||||||
|
2. **It is frontmatter-scoped.** The failure mode that killed the old fallback was silently reading
|
||||||
|
past or short of the block. This one extracts the block explicitly and errors out when it cannot
|
||||||
|
find a closing marker, so "could not parse" is a red, never a green.
|
||||||
|
3. **It is pinned by tests.** `tests/test-skill-frontmatter.sh` drives the hook through pre-commit's
|
||||||
|
real `bash -c <script> <arg0> <files…>` invocation and asserts each defect class above. The
|
||||||
|
deleted fallback had no such suite; that is how its disagreement with a real parser survived.
|
||||||
|
|
||||||
|
The trade it buys is that the hook stays repo-local. Moving it to a script would change the
|
||||||
|
externally exposed `.pre-commit-hooks.yaml` contract for consumers, for a check that has no need of a
|
||||||
|
YAML parser.
|
||||||
|
|
||||||
### 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):
|
||||||
@@ -413,6 +468,15 @@ reader that mis-parses an unfamiliar scalar shape reports a clean pass on a file
|
|||||||
which is the exact vacuous-green failure the `python3` check exists to avoid. `pip install pyyaml`
|
which is the exact vacuous-green failure the `python3` check exists to avoid. `pip install pyyaml`
|
||||||
(or `python3 -m pip install PyYAML`, or the distro's `python3-yaml`) if the hook reports it missing.
|
(or `python3 -m pip install PyYAML`, or the distro's `python3-yaml`) if the hook reports it missing.
|
||||||
|
|
||||||
|
**Neither requirement generalises to every hook on this scope, and one deliberate exception sits
|
||||||
|
right next to it.** [`skill-frontmatter`](#skill-frontmatter-the-other-hook-on-that-scope) runs on the
|
||||||
|
same `files:` pattern as a **shell** parser, on purpose — it asks only whether a key is on a line and
|
||||||
|
whether one short plain scalar matches `N.N.N`, with no folding to get wrong, and moving it to a
|
||||||
|
script would change the externally exposed `.pre-commit-hooks.yaml` contract for consumers. That
|
||||||
|
section carries the full argument. A reader arriving here first should not read this one as
|
||||||
|
condemning it. `check-rtk-prefix` needs `python3` but **not** PyYAML: it reads the markdown body and
|
||||||
|
never touches frontmatter, so it has no scalar to fold.
|
||||||
|
|
||||||
## Agent files take the description gates, not the body gate
|
## Agent files take the description gates, not the body gate
|
||||||
|
|
||||||
`check-apm-agents-valid` runs agent-audit's `validate.sh` over every real
|
`check-apm-agents-valid` runs agent-audit's `validate.sh` over every real
|
||||||
@@ -497,6 +561,91 @@ pre-commit run --all-files # size AND Vale
|
|||||||
|
|
||||||
Scoping a retrofit off `skill-size-check` output alone leaves you blocked at the second gate.
|
Scoping a retrofit off `skill-size-check` output alone leaves you blocked at the second gate.
|
||||||
|
|
||||||
|
## The `rtk` prefix gate (ADR-0023)
|
||||||
|
|
||||||
|
`check-rtk-prefix` is a `repo: local` pre-commit hook running `scripts/check-rtk-prefix.sh` over
|
||||||
|
`^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$`, with `README.md` excluded. It enforces
|
||||||
|
**ADR-0023 clause 1 and nothing else**: an executable, instructed local git command in plugin skill
|
||||||
|
or agent content is written `rtk git`.
|
||||||
|
|
||||||
|
It is wider in file scope than the ADR-0020 hooks — every markdown file under a plugin's
|
||||||
|
`.apm/skills/` and `.apm/agents/`, not `SKILL.md` alone — because the rule it enforces is about
|
||||||
|
commands an agent runs, and most of those live in `references/`, which the ADR-0020 gates do not
|
||||||
|
reach ([the `references/` blind spot](#the-blind-spot-references-is-unlinted-for-two-independent-reasons)).
|
||||||
|
|
||||||
|
### What it can decide, and what it declines to
|
||||||
|
|
||||||
|
ADR-0023 has three clauses and only the first is a pattern:
|
||||||
|
|
||||||
|
| Clause | Rule | Gated |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | executable + instructed → `rtk git` | yes |
|
||||||
|
| 2 | illustrative / referential → bare `git` | no — undecidable |
|
||||||
|
| 3 | machine-parsed or interactive → bare `git` | no — opt-out marker |
|
||||||
|
|
||||||
|
Clause 2 is a judgement about what a sentence is *doing*. "Run `git switch <branch>`" and "`git
|
||||||
|
switch` refuses rather than clobbering local edits" are the same token sequence. A gate that guessed
|
||||||
|
would fire on correct prose, and **a gate that fires on correct content gets added to `SKIP`** —
|
||||||
|
which disarms clause 1 along with it. So the hook looks only at the two contexts where a `git`
|
||||||
|
mention is unambiguously an instruction to execute:
|
||||||
|
|
||||||
|
- a line inside a fenced code block whose info string names a shell — `bash`, `sh`, `shell`, `zsh`,
|
||||||
|
`console`, `shell-session`. Fences tagged `text`, `yaml`, `json`, or tagged with nothing, are **not**
|
||||||
|
checked;
|
||||||
|
- the **opening** backticked span of a "Run" column cell in a markdown dispatch table, and only the
|
||||||
|
opening span.
|
||||||
|
|
||||||
|
That last narrowing is not fussiness. A Run cell routinely carries a command followed by prose about
|
||||||
|
it, and the prose is clause 2. `git-worktrees/SKILL.md` has both shapes on adjacent rows — one cell
|
||||||
|
reading `` `rtk git worktree add --track …` `` — always correct. `` `git worktree add <path>
|
||||||
|
<branch>` `` expands to exactly this (instruction, then reference), and a `**Never** …` row whose Run
|
||||||
|
cell is entirely explanation containing a bare `git push`. Checking every backticked span flags both;
|
||||||
|
checking only a leading span flags neither, and still catches the ordinary
|
||||||
|
`` | List | `git worktree list -v` | `` case the gate exists for.
|
||||||
|
|
||||||
|
### The clause-3 opt-out
|
||||||
|
|
||||||
|
A command that is deliberately bare — because rtk rewrites the output the skill parses, or because
|
||||||
|
the command is interactive — is exempted by putting the literal string `ADR-0023` **on the same
|
||||||
|
line**: in a shell comment for a code line, in the cell text for a table row.
|
||||||
|
|
||||||
|
Per line, never per block. A fenced procedure routinely mixes `rtk git` steps with one deliberately
|
||||||
|
bare command (`git-remotes/references/push.md` does exactly that), and a block-level marker would
|
||||||
|
silently disarm every checked line around the marked one. The cost is a repeated `# bare per
|
||||||
|
ADR-0023` in the three blocks of `git-log-format.md` where every line is deliberately bare; that
|
||||||
|
repetition is the price of the marked line being the only line the marker speaks for.
|
||||||
|
|
||||||
|
The marker is a plain substring match, so a line that mentions `ADR-0023` for an unrelated reason is
|
||||||
|
also exempt. Accepted deliberately: the marker records an author's opt-out, it is not a security
|
||||||
|
boundary, and a stricter form would only move the same trust to a different string.
|
||||||
|
|
||||||
|
### What it deliberately does not cover
|
||||||
|
|
||||||
|
- **Clause 2.** Nothing checks that an illustrative mention stayed bare. A sweep that re-prefixes a
|
||||||
|
referential `git` passes this gate. The inline reasons ADR-0023 requires on clause-3 sites are the
|
||||||
|
only defence, and they are prose.
|
||||||
|
- **Prose bullets.** Most of `branch-operations.md`, `merging.md` and `rewrite-history.md` instruct
|
||||||
|
in list items, not fences. Those are clause-1 sites the gate cannot see, because it cannot
|
||||||
|
distinguish them from clause-2 mentions in the same list.
|
||||||
|
- **`README.md`, excluded by pattern.** A skill-directory README is consumer-facing prose no agent
|
||||||
|
loads, and the `git clone https://github.com/bats-core/…` lines in the seven `tests/README.md`
|
||||||
|
files are setup instructions for a third party who has no `rtk`. Prefixing those would be actively
|
||||||
|
wrong, not merely noisy — see ADR-0023's consumer section.
|
||||||
|
- **Quoting.** The line splitter breaks on `;`, `|`, `&&`, `||`, `$(` and backticks without tracking
|
||||||
|
quotes, so a git command inside a quoted argument is decided by accident.
|
||||||
|
`rtk git submodule foreach 'git pull origin main || :'` passes because the segment holding the
|
||||||
|
inner command begins with `rtk` — the right answer for the wrong reason. Write
|
||||||
|
`foreach 'git a; git b'` and the second inner command is a false positive needing the marker.
|
||||||
|
ADR-0023 records this shape as one the rule itself does not decide.
|
||||||
|
- **Non-git commands.** Only `git` is checked. `rtk` fronts `gh`, `docker`, `kubectl` and others; no
|
||||||
|
gate covers those, and the corpus does not currently instruct them.
|
||||||
|
|
||||||
|
`tests/test-check-rtk-prefix.sh` pins all of it, including the false-positive cases. Its first case
|
||||||
|
reconstructs the plugin corpus as it stood on `main` before the #113 sweep and asserts the gate
|
||||||
|
fails there with at least 20 findings, one of them the `gitea-*` `git remote get-url origin` drift
|
||||||
|
the sweep missed — a gate that only passes on the already-fixed tree proves nothing about the drift
|
||||||
|
it was written for.
|
||||||
|
|
||||||
## Vale
|
## Vale
|
||||||
|
|
||||||
Install the `vale` binary — `brew install vale` (macOS), `snap install vale` (Linux),
|
Install the `vale` binary — `brew install vale` (macOS), `snap install vale` (Linux),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: >
|
|||||||
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
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0"
|
version: "1.0.0"
|
||||||
category: implement
|
category: implement
|
||||||
source:
|
source:
|
||||||
- repo: anthropics/skills
|
- repo: anthropics/skills
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
2
plugins/bin/.github/plugin/plugin.json
vendored
2
plugins/bin/.github/plugin/plugin.json
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
"description": "Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: bin
|
name: bin
|
||||||
version: 1.1.6
|
version: 1.1.7
|
||||||
description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.
|
description: Skills for everyday AI-assisted development work that is not tied to a single tool, forge or language, and has not yet been split into a focused plugin.
|
||||||
author:
|
author:
|
||||||
name: Defame1297
|
name: Defame1297
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: >
|
|||||||
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
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0"
|
version: "1.0.0"
|
||||||
category: implement
|
category: implement
|
||||||
source:
|
source:
|
||||||
- repo: anthropics/skills
|
- repo: anthropics/skills
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: >
|
|||||||
Not a Gitea remote's branches -> `gitea-branches`.
|
Not a Gitea remote's branches -> `gitea-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-git-htmldocs
|
- context7-git-htmldocs
|
||||||
@@ -21,7 +21,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.** Detect it before acting — `rtk git branch --list <name>` and `rtk 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>`.
|
- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list <name>` (bare, not `rtk`: rtk prints a phantom `* ` line even on no match, which reports every name as ambiguous — ADR-0023) and `rtk 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
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ past it: it shelves the working tree and index so the branch pointer can move.
|
|||||||
- **save** — `rtk git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git
|
- **save** — `rtk git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git
|
||||||
2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes
|
2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes
|
||||||
reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message.
|
reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message.
|
||||||
- **restore** — `rtk git stash pop` applies the newest entry and deletes it. `rtk git stash apply stash@{n}`
|
- **restore** — `git stash pop` applies the newest entry and deletes it. Bare, not `rtk`: on a
|
||||||
|
conflict rtk prints only `FAILED: git stash pop` and swallows the conflict report the paragraph
|
||||||
|
below tells you to read (ADR-0023). `rtk git stash apply stash@{n}`
|
||||||
applies without deleting, for replaying one shelf onto more than one branch.
|
applies without deleting, for replaying one shelf onto more than one branch.
|
||||||
- **list** — `rtk git stash list`; `rtk git stash show -p stash@{n}` prints that entry's diff.
|
- **list** — `git stash list` — bare, not `rtk`: rtk prints `No stashes` where git prints nothing,
|
||||||
|
so an empty-output test misfires (ADR-0023). `rtk git stash show -p stash@{n}` prints that entry's diff.
|
||||||
- **drop** — `rtk git stash drop stash@{n}` deletes one entry. `rtk git stash clear` deletes all of them
|
- **drop** — `rtk git stash drop stash@{n}` deletes one entry. `rtk git stash clear` deletes all of them
|
||||||
and nothing recovers them — confirm before running it.
|
and nothing recovers them — confirm before running it.
|
||||||
- **branch from a stash** — `rtk git stash branch <branch> stash@{n}` creates a branch at the commit the
|
- **branch from a stash** — `rtk git stash branch <branch> stash@{n}` creates a branch at the commit the
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ list the conflicted files, edit each to resolve its markers, then `rtk git add <
|
|||||||
`rtk git merge --continue`.
|
`rtk git merge --continue`.
|
||||||
|
|
||||||
- `rtk git merge --abort` restores the pre-merge state.
|
- `rtk git merge --abort` restores the pre-merge state.
|
||||||
- `rtk git mergetool` opens the configured merge tool.
|
- `git mergetool` opens the configured merge tool — bare, not `rtk`: it hands control to an
|
||||||
|
interactive child process, and a token filter has nothing to offer there (ADR-0023).
|
||||||
- `rtk git diff --diff-filter=U` shows only the still-conflicted files.
|
- `rtk git diff --diff-filter=U` shows only the still-conflicted files.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
Not branch lifecycle -> `git-branches`.
|
Not branch lifecycle -> `git-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "0.1.3"
|
version: "0.1.4"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- conventional-commits-spec
|
- conventional-commits-spec
|
||||||
@@ -21,7 +21,7 @@ allowed-tools: Bash
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too.
|
- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. Exceptions: ADR-0023 clause 3.
|
||||||
- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it.
|
- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it.
|
||||||
- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first.
|
- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first.
|
||||||
- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning.
|
- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning.
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark
|
|||||||
|
|
||||||
1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit.
|
1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit.
|
||||||
2. Get explicit approval — the rebase still rewrites history.
|
2. Get explicit approval — the rebase still rewrites history.
|
||||||
3. Run `rtk git rebase -i --autosquash HEAD~N`. Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply.
|
3. Run `git rebase -i --autosquash HEAD~N` — bare, not `rtk`: `-i` opens an interactive sequence editor (ADR-0023). Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply.
|
||||||
|
|
||||||
**`-i` is not optional here.** On Git 2.39.5, `rtk git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that.
|
**`-i` is not optional here.** On Git 2.39.5, `git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that.
|
||||||
|
|
||||||
## Squash by hand (interactive rebase)
|
## Squash by hand (interactive rebase)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Use this when the commits were not tagged at commit time. **Interactive rebase h
|
|||||||
|
|
||||||
1. Identify the commits to squash — typically the last N on the current branch.
|
1. Identify the commits to squash — typically the last N on the current branch.
|
||||||
2. Get explicit approval.
|
2. Get explicit approval.
|
||||||
3. Run `rtk git rebase -i HEAD~N`, marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them.
|
3. Run `git rebase -i HEAD~N` — bare, not `rtk`, for the same interactive-editor reason — marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them.
|
||||||
4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`.
|
4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`.
|
||||||
|
|
||||||
## When a rebase halts on a conflict
|
## When a rebase halts on a conflict
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
`git-commits`. Not a Gitea server's history -> `gitea-branches`.
|
`git-commits`. Not a Gitea server's history -> `gitea-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-bisect-docs
|
- git-scm-bisect-docs
|
||||||
@@ -38,7 +38,7 @@ allowed-tools: Bash
|
|||||||
Default to `rtk git log --oneline`, then narrow by whatever is known:
|
Default to `rtk git log --oneline`, then narrow by whatever is known:
|
||||||
|
|
||||||
- **Content**: `rtk git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset.
|
- **Content**: `rtk git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset.
|
||||||
- **A line or function**: `rtk git log -L <start>,<end>:<file>` or `rtk git log -L :<function>:<file>`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target.
|
- **A line or function**: `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>` — bare, not `rtk`: rtk truncates each diff line at ~72 characters (ADR-0023). Confirm the range resolves before reporting on it — an off-by-one silently omits the target.
|
||||||
- **A file across renames**: `rtk git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary.
|
- **A file across renames**: `rtk git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary.
|
||||||
- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches.
|
- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches.
|
||||||
- **Structured output**: `rtk git log --format="%h | %s | %an (%ar)"`.
|
- **Structured output**: `rtk git log --format="%h | %s | %an (%ar)"`.
|
||||||
|
|||||||
@@ -161,11 +161,15 @@ rtk git log --diff-filter=M # only show commits with modified files
|
|||||||
|
|
||||||
Traces the evolution of a specific range of lines or a named function through commits. Implies `--patch`.
|
Traces the evolution of a specific range of lines or a named function through commits. Implies `--patch`.
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`, on every `-L` form below: rtk truncates each diff body
|
||||||
|
line at roughly 72 characters with an ellipsis, on the one query whose whole point
|
||||||
|
is showing line content.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git log -L 10,20:file.txt
|
git log -L 10,20:file.txt # bare per ADR-0023
|
||||||
rtk git log -L /start_pattern/,/end_pattern/:file.txt
|
git log -L /start_pattern/,/end_pattern/:file.txt # bare per ADR-0023
|
||||||
rtk git log -L :myfunction:src/app.c
|
git log -L :myfunction:src/app.c # bare per ADR-0023
|
||||||
rtk git log -L /init/,+15:config.py # 15 lines after first match of /init/
|
git log -L /init/,+15:config.py # bare per ADR-0023; 15 lines after first /init/ match
|
||||||
```
|
```
|
||||||
|
|
||||||
Range formats:
|
Range formats:
|
||||||
@@ -205,20 +209,26 @@ rtk git diff --numstat # machine-readable: <added>\t<deleted>\t<p
|
|||||||
|
|
||||||
### --name-only / --name-status
|
### --name-only / --name-status
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`: rtk appends a blank line and a `Changes:` trailer, so
|
||||||
|
the output is no longer one record per line.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git diff --name-only # only filenames, one per line
|
git diff --name-only # bare per ADR-0023; only filenames, one per line
|
||||||
rtk git diff --name-status # status letter + filename per line
|
git diff --name-status # bare per ADR-0023; status letter + filename per line
|
||||||
```
|
```
|
||||||
|
|
||||||
`--name-status` uses the same status letters as `--diff-filter`.
|
`--name-status` uses the same status letters as `--diff-filter`.
|
||||||
|
|
||||||
### --word-diff
|
### --word-diff
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`: rtk replaces the word-diff with its own diffstat
|
||||||
|
renderer and emits none of the `[-removed-] {+added+}` markers.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git diff --word-diff # inline word-level diff with [-removed-] {+added+} markers
|
git diff --word-diff # bare per ADR-0023; inline word-level diff, [-removed-] {+added+} markers
|
||||||
rtk git diff --word-diff=color # color only, no markers
|
git diff --word-diff=color # bare per ADR-0023; color only, no markers
|
||||||
rtk git diff --word-diff=porcelain # machine-readable: +/- prefixed lines, ~ for newlines
|
git diff --word-diff=porcelain # bare per ADR-0023; machine-readable: +/- prefixed lines, ~ for newlines
|
||||||
rtk git diff --word-diff-regex=<re> # define what counts as a "word"
|
git diff --word-diff-regex=<re> # bare per ADR-0023; define what counts as a "word"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Whitespace Flags
|
### Whitespace Flags
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ description: >
|
|||||||
Not submodule pointers -> `git-submodules`.
|
Not submodule pointers -> `git-submodules`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-remote-docs
|
- git-scm-remote-docs
|
||||||
|
|||||||
@@ -48,13 +48,15 @@ Two mitigations:
|
|||||||
# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push
|
# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push
|
||||||
# through a separate remote that nothing else touches, so its tracking ref can't be
|
# through a separate remote that nothing else touches, so its tracking ref can't be
|
||||||
# poisoned by an unrelated fetch.
|
# poisoned by an unrelated fetch.
|
||||||
rtk git remote add origin-push $(rtk git config remote.origin.url)
|
# The inner `git config` is bare: its stdout becomes a remote URL, so any
|
||||||
|
# output rewriting would poison the remote silently.
|
||||||
|
rtk git remote add origin-push $(git config remote.origin.url) # inner bare per ADR-0023
|
||||||
rtk git push --force-with-lease origin-push
|
rtk git push --force-with-lease origin-push
|
||||||
|
|
||||||
# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state
|
# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state
|
||||||
rtk git fetch
|
rtk git fetch
|
||||||
rtk git tag base master
|
rtk git tag base master
|
||||||
rtk git rebase -i master
|
git rebase -i master # bare, not `rtk` (ADR-0023): interactive sequence editor
|
||||||
rtk git push --force-with-lease=master:base master:master
|
rtk git push --force-with-lease=master:base master:master
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
Not interactive multi-step git guidance -> `git-workflow`.
|
Not interactive multi-step git guidance -> `git-workflow`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-worktree-docs
|
- git-scm-worktree-docs
|
||||||
@@ -32,7 +32,7 @@ metadata:
|
|||||||
| Create a local branch tracking a remote one | `rtk git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` |
|
| Create a local branch tracking a remote one | `rtk git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` |
|
||||||
| Throwaway experiment, no branch | `rtk git worktree add -d <path>` — detached HEAD |
|
| Throwaway experiment, no branch | `rtk git worktree add -d <path>` — detached HEAD |
|
||||||
| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above |
|
| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above |
|
||||||
| List | `rtk git worktree list -v`, or `--porcelain -z` to parse |
|
| List | `git worktree list -v` to read, or `git worktree list --porcelain -z` to parse — both bare per ADR-0023: rtk re-renders the output and drops the porcelain flags |
|
||||||
| Lock or unlock | `rtk git worktree lock [--reason <str>] <path>` / `rtk git worktree unlock <path>` |
|
| Lock or unlock | `rtk git worktree lock [--reason <str>] <path>` / `rtk git worktree unlock <path>` |
|
||||||
| Move | `rtk git worktree move <from> <to>` |
|
| Move | `rtk git worktree move <from> <to>` |
|
||||||
| Remove | `rtk git worktree remove <path>` |
|
| Remove | `rtk git worktree remove <path>` |
|
||||||
@@ -62,6 +62,7 @@ worktrees:
|
|||||||
lock_reason: <reason or empty>
|
lock_reason: <reason or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
Derive those fields from `rtk git worktree list --porcelain -z`. For a single
|
Derive those fields from `git worktree list --porcelain -z` — bare, not `rtk`:
|
||||||
|
rtk drops both flags and never emits `locked`/`lock_reason` (ADR-0023). For a single
|
||||||
operation, report its outcome instead — `created: true`, `moved: true`,
|
operation, report its outcome instead — `created: true`, `moved: true`,
|
||||||
`removed: true`.
|
`removed: true`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
compatibility: Requires pre-commit installed and available on PATH.
|
compatibility: Requires pre-commit installed and available on PATH.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: devtools
|
category: devtools
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-pre-commit-com
|
- context7-pre-commit-com
|
||||||
@@ -19,7 +19,7 @@ allowed-tools: Bash Read
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring.
|
- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks rtk git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring.
|
||||||
- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead.
|
- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead.
|
||||||
- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `rtk git add -u && rtk git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging.
|
- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `rtk git add -u && rtk git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
"version": "1.3.6",
|
"version": "1.3.7",
|
||||||
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
2
plugins/git/.github/plugin/plugin.json
vendored
2
plugins/git/.github/plugin/plugin.json
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
"version": "1.3.6",
|
"version": "1.3.7",
|
||||||
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
"description": "Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
@@ -30,12 +30,9 @@ copilot plugin install ./plugins/git
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Every skill in this plugin runs local git commands through the org's `rtk` wrapper, never bare `git` — see `git-commits/SKILL.md`'s Gotchas and `git-workflow/references/hard-rules.md`. Issue #113 made that rule precise for skill *prose*, where a `git <subcommand>` mention can be either an instruction to execute or just a reference to the concept:
|
Skills here run local git commands through the org's `rtk` wrapper. **When a command is prefixed, when it stays bare, and why some executable commands must stay bare are all decided by ADR-0023** (`docs/adr/0023-rtk-prefix-marks-executable-commands-only.md`), which is repo-wide and not specific to this plugin. `check-rtk-prefix` enforces the part of it that is machine-decidable.
|
||||||
|
|
||||||
- **Executable, instructed commands** — anything telling the agent to run a command right now (an imperative step, a dispatch-table "Run" cell, a fenced code-block procedure) — use `rtk git`. Example: `rtk git push -u origin <branch>`.
|
Installing this plugin without `rtk`? Every prefixed command is a plain `git` invocation with a word in front of it — drop the `rtk ` and it is correct.
|
||||||
- **Illustrative or referential mentions** — naming a flag's behavior, quoting a doc's own heading, describing what a command does in the abstract, or warning against an anti-pattern — stay bare `git`. Example: "`git switch` refuses rather than clobbering conflicting local edits."
|
|
||||||
|
|
||||||
This applies within this plugin's own skill files (`git-*`, `pc-*`) — it does not generalize to other plugins. The `gitea-*` skills, for instance, talk to a remote Gitea server through the `gitea` MCP tools and contain no `git`/`rtk` mentions at all; the distinction has nothing to hold onto there.
|
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: git
|
name: git
|
||||||
version: 1.3.6
|
version: 1.3.7
|
||||||
description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.
|
description: Skills and agents for working with a local Git clone over the git wire protocol, and for authoring and running the pre-commit hooks that guard it.
|
||||||
author:
|
author:
|
||||||
name: Defame1297
|
name: Defame1297
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: >
|
|||||||
Not a Gitea remote's branches -> `gitea-branches`.
|
Not a Gitea remote's branches -> `gitea-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-git-htmldocs
|
- context7-git-htmldocs
|
||||||
@@ -21,7 +21,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.** Detect it before acting — `rtk git branch --list <name>` and `rtk 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>`.
|
- **A branch and a tag can carry the same name.** Detect it before acting — `git branch --list <name>` (bare, not `rtk`: rtk prints a phantom `* ` line even on no match, which reports every name as ambiguous — ADR-0023) and `rtk 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
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ past it: it shelves the working tree and index so the branch pointer can move.
|
|||||||
- **save** — `rtk git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git
|
- **save** — `rtk git stash push -m "<message>"`. Add `-u` to include untracked files; verified on Git
|
||||||
2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes
|
2.39.5, a plain `push` leaves them in place, and a plain `push` with *only* untracked changes
|
||||||
reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message.
|
reports `No local changes to save` and stashes nothing. Bare `git stash` is `push` with no message.
|
||||||
- **restore** — `rtk git stash pop` applies the newest entry and deletes it. `rtk git stash apply stash@{n}`
|
- **restore** — `git stash pop` applies the newest entry and deletes it. Bare, not `rtk`: on a
|
||||||
|
conflict rtk prints only `FAILED: git stash pop` and swallows the conflict report the paragraph
|
||||||
|
below tells you to read (ADR-0023). `rtk git stash apply stash@{n}`
|
||||||
applies without deleting, for replaying one shelf onto more than one branch.
|
applies without deleting, for replaying one shelf onto more than one branch.
|
||||||
- **list** — `rtk git stash list`; `rtk git stash show -p stash@{n}` prints that entry's diff.
|
- **list** — `git stash list` — bare, not `rtk`: rtk prints `No stashes` where git prints nothing,
|
||||||
|
so an empty-output test misfires (ADR-0023). `rtk git stash show -p stash@{n}` prints that entry's diff.
|
||||||
- **drop** — `rtk git stash drop stash@{n}` deletes one entry. `rtk git stash clear` deletes all of them
|
- **drop** — `rtk git stash drop stash@{n}` deletes one entry. `rtk git stash clear` deletes all of them
|
||||||
and nothing recovers them — confirm before running it.
|
and nothing recovers them — confirm before running it.
|
||||||
- **branch from a stash** — `rtk git stash branch <branch> stash@{n}` creates a branch at the commit the
|
- **branch from a stash** — `rtk git stash branch <branch> stash@{n}` creates a branch at the commit the
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ list the conflicted files, edit each to resolve its markers, then `rtk git add <
|
|||||||
`rtk git merge --continue`.
|
`rtk git merge --continue`.
|
||||||
|
|
||||||
- `rtk git merge --abort` restores the pre-merge state.
|
- `rtk git merge --abort` restores the pre-merge state.
|
||||||
- `rtk git mergetool` opens the configured merge tool.
|
- `git mergetool` opens the configured merge tool — bare, not `rtk`: it hands control to an
|
||||||
|
interactive child process, and a token filter has nothing to offer there (ADR-0023).
|
||||||
- `rtk git diff --diff-filter=U` shows only the still-conflicted files.
|
- `rtk git diff --diff-filter=U` shows only the still-conflicted files.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
Not branch lifecycle -> `git-branches`.
|
Not branch lifecycle -> `git-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "0.1.3"
|
version: "0.1.4"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- conventional-commits-spec
|
- conventional-commits-spec
|
||||||
@@ -21,7 +21,7 @@ allowed-tools: Bash
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too.
|
- **Run git as `rtk git <subcommand>`, never bare `git`** — org convention, in `&&` chains too. Exceptions: ADR-0023 clause 3.
|
||||||
- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it.
|
- **Refuse to force-push `main`/`master`** — a rewrite leaves the branch diverged and the reflex is to force it back; safe only where nobody else has based work on it.
|
||||||
- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first.
|
- **`reset --hard` is a confirmation gate, not a default.** It overwrites the working tree, and uncommitted edits it discards were never in git, so no reflog recovers them. Name what will be lost and offer a stash first.
|
||||||
- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning.
|
- **Never add `--no-verify`** — using it when a hook fails bypasses the QA gate the pipeline depends on. Only on the user's explicit demand, with a warning.
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Prefer this whenever a commit is written to be folded, because git does the mark
|
|||||||
|
|
||||||
1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit.
|
1. `rtk git commit --fixup=<commit>` keeps the target's message; `rtk git commit --squash=<commit>` lets you edit the combined message later. Both prefix the message with `fixup!`/`squash!` and name the target commit.
|
||||||
2. Get explicit approval — the rebase still rewrites history.
|
2. Get explicit approval — the rebase still rewrites history.
|
||||||
3. Run `rtk git rebase -i --autosquash HEAD~N`. Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply.
|
3. Run `git rebase -i --autosquash HEAD~N` — bare, not `rtk`: `-i` opens an interactive sequence editor (ADR-0023). Git pre-fills the todo list with the tagged commits already reordered against their targets; save it unchanged to apply.
|
||||||
|
|
||||||
**`-i` is not optional here.** On Git 2.39.5, `rtk git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that.
|
**`-i` is not optional here.** On Git 2.39.5, `git rebase --autosquash HEAD~N` without `-i` prints `Successfully rebased and updated refs/heads/<branch>.` and exits 0 while leaving the `fixup!` commit in place at its original SHA — `--autosquash` is honoured only by the interactive machinery, and the false success is the trap: the fold is reported as done, and the surviving `fixup!` subject then fails the Conventional Commits `commit-msg` hook. Later Git versions taught the non-interactive rebase to honour the flag, but `-i --autosquash` is correct on every version, so always write that.
|
||||||
|
|
||||||
## Squash by hand (interactive rebase)
|
## Squash by hand (interactive rebase)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Use this when the commits were not tagged at commit time. **Interactive rebase h
|
|||||||
|
|
||||||
1. Identify the commits to squash — typically the last N on the current branch.
|
1. Identify the commits to squash — typically the last N on the current branch.
|
||||||
2. Get explicit approval.
|
2. Get explicit approval.
|
||||||
3. Run `rtk git rebase -i HEAD~N`, marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them.
|
3. Run `git rebase -i HEAD~N` — bare, not `rtk`, for the same interactive-editor reason — marking the older commits `squash` to keep their messages for editing, or `fixup` to discard them.
|
||||||
4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`.
|
4. Compose the combined message when the rebase stops to ask. For a non-trivial combined message, follow the structure in `references/commit-template.md`.
|
||||||
|
|
||||||
## When a rebase halts on a conflict
|
## When a rebase halts on a conflict
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
`git-commits`. Not a Gitea server's history -> `gitea-branches`.
|
`git-commits`. Not a Gitea server's history -> `gitea-branches`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-bisect-docs
|
- git-scm-bisect-docs
|
||||||
@@ -38,7 +38,7 @@ allowed-tools: Bash
|
|||||||
Default to `rtk git log --oneline`, then narrow by whatever is known:
|
Default to `rtk git log --oneline`, then narrow by whatever is known:
|
||||||
|
|
||||||
- **Content**: `rtk git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset.
|
- **Content**: `rtk git log -S"string"`, or `-G"regex"` to match any diff line. `--pickaxe-regex` makes the `-S` argument a POSIX ERE; `--pickaxe-all` shows every file in a matching changeset.
|
||||||
- **A line or function**: `rtk git log -L <start>,<end>:<file>` or `rtk git log -L :<function>:<file>`. Confirm the range resolves before reporting on it — an off-by-one silently omits the target.
|
- **A line or function**: `git log -L <start>,<end>:<file>` or `git log -L :<function>:<file>` — bare, not `rtk`: rtk truncates each diff line at ~72 characters (ADR-0023). Confirm the range resolves before reporting on it — an off-by-one silently omits the target.
|
||||||
- **A file across renames**: `rtk git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary.
|
- **A file across renames**: `rtk git log --follow -- <file>`. Without `--follow` the history stops at the rename boundary.
|
||||||
- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches.
|
- **Mainline only**: `--first-parent` follows the integration branch and skips commits merged in from side branches.
|
||||||
- **Structured output**: `rtk git log --format="%h | %s | %an (%ar)"`.
|
- **Structured output**: `rtk git log --format="%h | %s | %an (%ar)"`.
|
||||||
|
|||||||
@@ -161,11 +161,15 @@ rtk git log --diff-filter=M # only show commits with modified files
|
|||||||
|
|
||||||
Traces the evolution of a specific range of lines or a named function through commits. Implies `--patch`.
|
Traces the evolution of a specific range of lines or a named function through commits. Implies `--patch`.
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`, on every `-L` form below: rtk truncates each diff body
|
||||||
|
line at roughly 72 characters with an ellipsis, on the one query whose whole point
|
||||||
|
is showing line content.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git log -L 10,20:file.txt
|
git log -L 10,20:file.txt # bare per ADR-0023
|
||||||
rtk git log -L /start_pattern/,/end_pattern/:file.txt
|
git log -L /start_pattern/,/end_pattern/:file.txt # bare per ADR-0023
|
||||||
rtk git log -L :myfunction:src/app.c
|
git log -L :myfunction:src/app.c # bare per ADR-0023
|
||||||
rtk git log -L /init/,+15:config.py # 15 lines after first match of /init/
|
git log -L /init/,+15:config.py # bare per ADR-0023; 15 lines after first /init/ match
|
||||||
```
|
```
|
||||||
|
|
||||||
Range formats:
|
Range formats:
|
||||||
@@ -205,20 +209,26 @@ rtk git diff --numstat # machine-readable: <added>\t<deleted>\t<p
|
|||||||
|
|
||||||
### --name-only / --name-status
|
### --name-only / --name-status
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`: rtk appends a blank line and a `Changes:` trailer, so
|
||||||
|
the output is no longer one record per line.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git diff --name-only # only filenames, one per line
|
git diff --name-only # bare per ADR-0023; only filenames, one per line
|
||||||
rtk git diff --name-status # status letter + filename per line
|
git diff --name-status # bare per ADR-0023; status letter + filename per line
|
||||||
```
|
```
|
||||||
|
|
||||||
`--name-status` uses the same status letters as `--diff-filter`.
|
`--name-status` uses the same status letters as `--diff-filter`.
|
||||||
|
|
||||||
### --word-diff
|
### --word-diff
|
||||||
|
|
||||||
|
Bare `git`, not `rtk git`: rtk replaces the word-diff with its own diffstat
|
||||||
|
renderer and emits none of the `[-removed-] {+added+}` markers.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rtk git diff --word-diff # inline word-level diff with [-removed-] {+added+} markers
|
git diff --word-diff # bare per ADR-0023; inline word-level diff, [-removed-] {+added+} markers
|
||||||
rtk git diff --word-diff=color # color only, no markers
|
git diff --word-diff=color # bare per ADR-0023; color only, no markers
|
||||||
rtk git diff --word-diff=porcelain # machine-readable: +/- prefixed lines, ~ for newlines
|
git diff --word-diff=porcelain # bare per ADR-0023; machine-readable: +/- prefixed lines, ~ for newlines
|
||||||
rtk git diff --word-diff-regex=<re> # define what counts as a "word"
|
git diff --word-diff-regex=<re> # bare per ADR-0023; define what counts as a "word"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Whitespace Flags
|
### Whitespace Flags
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ description: >
|
|||||||
Not submodule pointers -> `git-submodules`.
|
Not submodule pointers -> `git-submodules`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-remote-docs
|
- git-scm-remote-docs
|
||||||
|
|||||||
@@ -48,13 +48,15 @@ Two mitigations:
|
|||||||
# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push
|
# Option 1 — dedicated push-only remote: background tools fetch `origin`, you push
|
||||||
# through a separate remote that nothing else touches, so its tracking ref can't be
|
# through a separate remote that nothing else touches, so its tracking ref can't be
|
||||||
# poisoned by an unrelated fetch.
|
# poisoned by an unrelated fetch.
|
||||||
rtk git remote add origin-push $(rtk git config remote.origin.url)
|
# The inner `git config` is bare: its stdout becomes a remote URL, so any
|
||||||
|
# output rewriting would poison the remote silently.
|
||||||
|
rtk git remote add origin-push $(git config remote.origin.url) # inner bare per ADR-0023
|
||||||
rtk git push --force-with-lease origin-push
|
rtk git push --force-with-lease origin-push
|
||||||
|
|
||||||
# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state
|
# Option 2 — explicit SHA via a local tag, unaffected by tracking-branch state
|
||||||
rtk git fetch
|
rtk git fetch
|
||||||
rtk git tag base master
|
rtk git tag base master
|
||||||
rtk git rebase -i master
|
git rebase -i master # bare, not `rtk` (ADR-0023): interactive sequence editor
|
||||||
rtk git push --force-with-lease=master:base master:master
|
rtk git push --force-with-lease=master:base master:master
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
Not interactive multi-step git guidance -> `git-workflow`.
|
Not interactive multi-step git guidance -> `git-workflow`.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: git
|
category: git
|
||||||
source_keys:
|
source_keys:
|
||||||
- git-scm-worktree-docs
|
- git-scm-worktree-docs
|
||||||
@@ -32,7 +32,7 @@ metadata:
|
|||||||
| Create a local branch tracking a remote one | `rtk git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` |
|
| Create a local branch tracking a remote one | `rtk git worktree add --track -b <branch> <path> <remote>/<branch>` — always correct. `git worktree add <path> <branch>` expands to exactly this, but **only** under the conditions in `references/worktrees.md` |
|
||||||
| Throwaway experiment, no branch | `rtk git worktree add -d <path>` — detached HEAD |
|
| Throwaway experiment, no branch | `rtk git worktree add -d <path>` — detached HEAD |
|
||||||
| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above |
|
| **Never** `git worktree add <path> <remote>/<branch>` | That ref resolves, so the shortcut never fires and you get **a detached HEAD, no branch, no upstream**. Commits there go unreachable once HEAD moves, and `git push` needs an explicit refspec. Use the tracking row above |
|
||||||
| List | `rtk git worktree list -v`, or `--porcelain -z` to parse |
|
| List | `git worktree list -v` to read, or `git worktree list --porcelain -z` to parse — both bare per ADR-0023: rtk re-renders the output and drops the porcelain flags |
|
||||||
| Lock or unlock | `rtk git worktree lock [--reason <str>] <path>` / `rtk git worktree unlock <path>` |
|
| Lock or unlock | `rtk git worktree lock [--reason <str>] <path>` / `rtk git worktree unlock <path>` |
|
||||||
| Move | `rtk git worktree move <from> <to>` |
|
| Move | `rtk git worktree move <from> <to>` |
|
||||||
| Remove | `rtk git worktree remove <path>` |
|
| Remove | `rtk git worktree remove <path>` |
|
||||||
@@ -62,6 +62,7 @@ worktrees:
|
|||||||
lock_reason: <reason or empty>
|
lock_reason: <reason or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
Derive those fields from `rtk git worktree list --porcelain -z`. For a single
|
Derive those fields from `git worktree list --porcelain -z` — bare, not `rtk`:
|
||||||
|
rtk drops both flags and never emits `locked`/`lock_reason` (ADR-0023). For a single
|
||||||
operation, report its outcome instead — `created: true`, `moved: true`,
|
operation, report its outcome instead — `created: true`, `moved: true`,
|
||||||
`removed: true`.
|
`removed: true`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
compatibility: Requires pre-commit installed and available on PATH.
|
compatibility: Requires pre-commit installed and available on PATH.
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: devtools
|
category: devtools
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-pre-commit-com
|
- context7-pre-commit-com
|
||||||
@@ -19,7 +19,7 @@ allowed-tools: Bash Read
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring.
|
- The `SKIP` env var takes exact hook `id` values, comma-separated with no spaces: `SKIP=check-yaml,gitleaks rtk git commit -m "msg"`. A space after a comma silently skips nothing instead of erroring.
|
||||||
- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead.
|
- Never bypass a failing hook with `git commit --no-verify` (or `-n`). Hooks are the automated QA gate, so a bypassed commit pushes the failure downstream where it costs more — diagnose it instead.
|
||||||
- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `rtk git add -u && rtk git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging.
|
- `- files were modified by this hook` is not a bug. A fixer hook rewrote a staged file, so the staged snapshot is stale and the commit is blocked on purpose. Re-stage and re-run the same commit: `rtk git add -u && rtk git commit`. Do NOT reach for `pre-commit install -f` here — it overwrites `.git/hooks/` and has nothing to do with re-staging.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ disallowedTools: Edit, Write, NotebookEdit
|
|||||||
|
|
||||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
||||||
|
|
||||||
You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
You resolve `owner`/`repo` once per session (via `rtk git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
||||||
|
|
||||||
**Scope:** this orchestrator routes Gitea-object operations across the six domain skills only: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`. `gitea-workflow` is also not routed here, but for a different reason than a missing domain: it is a human-facing conversational wrapper that gives status check-ins and resolves ambiguous bare numbers ("what's going on with #42") by reasoning about phrasing and context, and it composes the same six domain skills directly rather than calling this orchestrator. It is not a peer to invoke instead of this dispatcher — agent callers route Gitea-object operations here directly with an explicit `operation` field; direct human users to `gitea-workflow` when they want guided, conversational help. Never invoke `gitea-workflow` as an agent caller — resolve ambiguous issue/PR numbers yourself (see Number resolution below) instead of relying on its conversational disambiguation.
|
**Scope:** this orchestrator routes Gitea-object operations across the six domain skills only: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`. `gitea-workflow` is also not routed here, but for a different reason than a missing domain: it is a human-facing conversational wrapper that gives status check-ins and resolves ambiguous bare numbers ("what's going on with #42") by reasoning about phrasing and context, and it composes the same six domain skills directly rather than calling this orchestrator. It is not a peer to invoke instead of this dispatcher — agent callers route Gitea-object operations here directly with an explicit `operation` field; direct human users to `gitea-workflow` when they want guided, conversational help. Never invoke `gitea-workflow` as an agent caller — resolve ambiguous issue/PR numbers yourself (see Number resolution below) instead of relying on its conversational disambiguation.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
|||||||
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
||||||
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
||||||
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
||||||
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `git remote -v`) to resolve context.
|
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `rtk git remote -v`) to resolve context.
|
||||||
|
|
||||||
### Number resolution
|
### Number resolution
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ When invoked, you:
|
|||||||
1. Validate the request structure and check if `operation` is known
|
1. Validate the request structure and check if `operation` is known
|
||||||
2. Check the request against the Hard rules above (default-branch deletion, release/tag id-vs-name asymmetry, label/milestone ID resolution, number-space ambiguity, pagination) — refuse outright on violation, independent of `confirm`
|
2. Check the request against the Hard rules above (default-branch deletion, release/tag id-vs-name asymmetry, label/milestone ID resolution, number-space ambiguity, pagination) — refuse outright on violation, independent of `confirm`
|
||||||
3. If destructive operation: require `confirm: true`, else fail with structured "requires explicit confirmation" error
|
3. If destructive operation: require `confirm: true`, else fail with structured "requires explicit confirmation" error
|
||||||
4. Resolve `owner`/`repo` via `git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request
|
4. Resolve `owner`/`repo` via `rtk git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request
|
||||||
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
||||||
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
||||||
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.2"
|
version: "0.1.3"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git
|
|||||||
Before any tool call, extract `owner` and `repo` from the git remote:
|
Before any tool call, extract `owner` and `repo` from the git remote:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ create_branch owner: <owner> repo: <repo> branch: <new-name> old_branch: <source
|
|||||||
|
|
||||||
Default dispatch: if the user gives a base ("branch off of X", "from X"), pass it as `old_branch`.
|
Default dispatch: if the user gives a base ("branch off of X", "from X"), pass it as `old_branch`.
|
||||||
If they don't specify a base and you're mid-task on a local branch, pass your current branch
|
If they don't specify a base and you're mid-task on a local branch, pass your current branch
|
||||||
(`git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
|
(`rtk git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
|
||||||
working, rather than silently falling back to the repo default. If neither applies (e.g. a fresh
|
working, rather than silently falling back to the repo default. If neither applies (e.g. a fresh
|
||||||
top-level request with no working branch context), omit `old_branch` and let it default server-side.
|
top-level request with no working branch context), omit `old_branch` and let it default server-side.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with write:issue and write:r
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.3"
|
version: "0.1.4"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i
|
|||||||
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:
|
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
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ metadata:
|
|||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
- context7-websites-gitea
|
- context7-websites-gitea
|
||||||
- context7-gitea-tea-cli
|
- context7-gitea-tea-cli
|
||||||
version: "0.1.4"
|
version: "0.1.5"
|
||||||
|
|
||||||
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
||||||
---
|
---
|
||||||
@@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
|
|||||||
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
|
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ metadata:
|
|||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
- context7-websites-gitea
|
- context7-websites-gitea
|
||||||
- context7-gitea-tea-cli
|
- context7-gitea-tea-cli
|
||||||
version: "0.1.2"
|
version: "0.1.3"
|
||||||
|
|
||||||
allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
|
allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
|
||||||
---
|
---
|
||||||
@@ -34,7 +34,7 @@ allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read
|
|||||||
Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in:
|
Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.0"
|
version: "0.1.1"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_releases mcp__gitea__get_release mcp__gitea
|
|||||||
`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already:
|
`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"version": "1.3.7",
|
"version": "1.3.8",
|
||||||
"description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.",
|
"description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
2
plugins/gitea/.github/plugin/plugin.json
vendored
2
plugins/gitea/.github/plugin/plugin.json
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"version": "1.3.7",
|
"version": "1.3.8",
|
||||||
"description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.",
|
"description": "Skills and agents for working with a Gitea forge through its HTTP API \u2014 the forge's own objects, as distinct from the local git clone.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ disallowedTools: Edit, Write, NotebookEdit
|
|||||||
|
|
||||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
||||||
|
|
||||||
You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
You resolve `owner`/`repo` once per session (via `rtk git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
||||||
|
|
||||||
**Scope:** this orchestrator routes Gitea-object operations across the six domain skills only: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`. `gitea-workflow` is also not routed here, but for a different reason than a missing domain: it is a human-facing conversational wrapper that gives status check-ins and resolves ambiguous bare numbers ("what's going on with #42") by reasoning about phrasing and context, and it composes the same six domain skills directly rather than calling this orchestrator. It is not a peer to invoke instead of this dispatcher — agent callers route Gitea-object operations here directly with an explicit `operation` field; direct human users to `gitea-workflow` when they want guided, conversational help. Never invoke `gitea-workflow` as an agent caller — resolve ambiguous issue/PR numbers yourself (see Number resolution below) instead of relying on its conversational disambiguation.
|
**Scope:** this orchestrator routes Gitea-object operations across the six domain skills only: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs`, `gitea-branches`, `gitea-files`, `gitea-releases`. `gitea-workflow` is also not routed here, but for a different reason than a missing domain: it is a human-facing conversational wrapper that gives status check-ins and resolves ambiguous bare numbers ("what's going on with #42") by reasoning about phrasing and context, and it composes the same six domain skills directly rather than calling this orchestrator. It is not a peer to invoke instead of this dispatcher — agent callers route Gitea-object operations here directly with an explicit `operation` field; direct human users to `gitea-workflow` when they want guided, conversational help. Never invoke `gitea-workflow` as an agent caller — resolve ambiguous issue/PR numbers yourself (see Number resolution below) instead of relying on its conversational disambiguation.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
|||||||
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
||||||
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
||||||
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
||||||
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `git remote -v`) to resolve context.
|
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `rtk git remote -v`) to resolve context.
|
||||||
|
|
||||||
### Number resolution
|
### Number resolution
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ When invoked, you:
|
|||||||
1. Validate the request structure and check if `operation` is known
|
1. Validate the request structure and check if `operation` is known
|
||||||
2. Check the request against the Hard rules above (default-branch deletion, release/tag id-vs-name asymmetry, label/milestone ID resolution, number-space ambiguity, pagination) — refuse outright on violation, independent of `confirm`
|
2. Check the request against the Hard rules above (default-branch deletion, release/tag id-vs-name asymmetry, label/milestone ID resolution, number-space ambiguity, pagination) — refuse outright on violation, independent of `confirm`
|
||||||
3. If destructive operation: require `confirm: true`, else fail with structured "requires explicit confirmation" error
|
3. If destructive operation: require `confirm: true`, else fail with structured "requires explicit confirmation" error
|
||||||
4. Resolve `owner`/`repo` via `git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request
|
4. Resolve `owner`/`repo` via `rtk git remote -v` on `origin` if not already present in `context`, and reuse the resolution for the remainder of the request
|
||||||
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
||||||
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
||||||
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: gitea
|
name: gitea
|
||||||
version: 1.3.7
|
version: 1.3.8
|
||||||
description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.
|
description: Skills and agents for working with a Gitea forge through its HTTP API — the forge's own objects, as distinct from the local git clone.
|
||||||
author:
|
author:
|
||||||
name: Defame1297
|
name: Defame1297
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.2"
|
version: "0.1.3"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__list_branches mcp__gitea__create_branch mcp__git
|
|||||||
Before any tool call, extract `owner` and `repo` from the git remote:
|
Before any tool call, extract `owner` and `repo` from the git remote:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
`get_me` and `list_my_repos` are blocked under the token scope this skill assumes, so the remote is the only source. If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ create_branch owner: <owner> repo: <repo> branch: <new-name> old_branch: <source
|
|||||||
|
|
||||||
Default dispatch: if the user gives a base ("branch off of X", "from X"), pass it as `old_branch`.
|
Default dispatch: if the user gives a base ("branch off of X", "from X"), pass it as `old_branch`.
|
||||||
If they don't specify a base and you're mid-task on a local branch, pass your current branch
|
If they don't specify a base and you're mid-task on a local branch, pass your current branch
|
||||||
(`git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
|
(`rtk git branch --show-current`) as `old_branch` so the new branch forks from where you're actually
|
||||||
working, rather than silently falling back to the repo default. If neither applies (e.g. a fresh
|
working, rather than silently falling back to the repo default. If neither applies (e.g. a fresh
|
||||||
top-level request with no working branch context), omit `old_branch` and let it default server-side.
|
top-level request with no working branch context), omit `old_branch` and let it default server-side.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with write:issue and write:r
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.3"
|
version: "0.1.4"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i
|
|||||||
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:
|
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
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is unset or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ metadata:
|
|||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
- context7-websites-gitea
|
- context7-websites-gitea
|
||||||
- context7-gitea-tea-cli
|
- context7-gitea-tea-cli
|
||||||
version: "0.1.4"
|
version: "0.1.5"
|
||||||
|
|
||||||
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
||||||
---
|
---
|
||||||
@@ -32,7 +32,7 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
|
|||||||
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
|
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ metadata:
|
|||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
- context7-websites-gitea
|
- context7-websites-gitea
|
||||||
- context7-gitea-tea-cli
|
- context7-gitea-tea-cli
|
||||||
version: "0.1.2"
|
version: "0.1.3"
|
||||||
|
|
||||||
allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
|
allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
|
||||||
---
|
---
|
||||||
@@ -34,7 +34,7 @@ allowed-tools: Bash mcp__gitea__list_pull_requests mcp__gitea__pull_request_read
|
|||||||
Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in:
|
Extract them from the git remote before any tool call, skipping this when an orchestrating caller already passed them in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ compatibility: Requires Gitea MCP server configured with a token with write:repo
|
|||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
category: integration
|
category: integration
|
||||||
version: "0.1.0"
|
version: "0.1.1"
|
||||||
source_keys:
|
source_keys:
|
||||||
- gitea-mcp-repo
|
- gitea-mcp-repo
|
||||||
- gitea-mcp-slim-go
|
- gitea-mcp-slim-go
|
||||||
@@ -36,7 +36,7 @@ allowed-tools: Bash mcp__gitea__list_releases mcp__gitea__get_release mcp__gitea
|
|||||||
`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already:
|
`owner` and `repo` are required on every tool below. Extract them from the git remote, unless an orchestrating caller passed them in already:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote get-url origin
|
rtk git remote get-url origin
|
||||||
```
|
```
|
||||||
|
|
||||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
directory -> skill-audit.
|
directory -> skill-audit.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-websites-code-claude
|
- context7-websites-code-claude
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ Read the frontmatter before judging a single word.
|
|||||||
Its Claude Code counterpart has no equivalent field and stays model-invoked, so the two halves of
|
Its Claude Code counterpart has no equivalent field and stays model-invoked, so the two halves of
|
||||||
the pair carrying differently shaped descriptions is expected there rather than a
|
the pair carrying differently shaped descriptions is expected there rather than a
|
||||||
pair-consistency finding.
|
pair-consistency finding.
|
||||||
`user-invocable: false` does not belong in this bullet: it only blocks manual invocation and is
|
`user-invocable: false` does not belong in this bullet. The two are separate fields with opposite
|
||||||
independent of `disable-model-invocation` — an agent can be `user-invocable: false` and still
|
defaults — `disable-model-invocation` (default `false`) governs runtime auto-selection,
|
||||||
model-routed, in which case the three-part shape below still applies. It carries no
|
`user-invocable` (default `true`) governs manual invocation, and the retired `infer` field was
|
||||||
|
replaced by the pair rather than by either one. So `user-invocable: false` says nothing about
|
||||||
|
whether the agent is model-routed: judge that from `disable-model-invocation` alone, and where
|
||||||
|
that is absent the three-part shape below still applies. `user-invocable` carries no
|
||||||
description-quality contract of its own and is out of this file's scope entirely.
|
description-quality contract of its own and is out of this file's scope entirely.
|
||||||
- **No such flag** — the agent is model-invoked and the rest of this file applies.
|
- **No such flag** — the agent is model-invoked and the rest of this file applies.
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ those same sections, so any restatement is a copy that can disagree with the che
|
|||||||
If it carries one, it still has to be kebab-case.
|
If it carries one, it still has to be kebab-case.
|
||||||
- `Use proactively` is meaningful in a CC description and steers the runtime to offer the agent
|
- `Use proactively` is meaningful in a CC description and steers the runtime to offer the agent
|
||||||
unprompted. In a Copilot description it does nothing; `KyberforgeCopilot.ProactivePhrase` flags
|
unprompted. In a Copilot description it does nothing; `KyberforgeCopilot.ProactivePhrase` flags
|
||||||
it. The Copilot equivalent is `disable-model-invocation` / `user-invocable`, which changes the
|
it. The Copilot equivalent is `disable-model-invocation`, which changes the description contract
|
||||||
description contract entirely — see `references/description-quality.md`, Step 0.
|
entirely — see `references/description-quality.md`, Step 0.
|
||||||
|
|
||||||
## Pair consistency
|
## Pair consistency
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
Not read-only review -> `agent-audit`. Not skills -> `skill-author`.
|
Not read-only review -> `agent-audit`. Not skills -> `skill-author`.
|
||||||
allowed-tools: Bash Read Write Edit
|
allowed-tools: Bash Read Write Edit
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-websites-code-claude
|
- context7-websites-code-claude
|
||||||
@@ -31,7 +31,7 @@ metadata:
|
|||||||
|
|
||||||
Signals: grill output, `agent-audit` findings, inline feedback, session context describing what went wrong. With none, ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?"
|
Signals: grill output, `agent-audit` findings, inline feedback, session context describing what went wrong. With none, ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?"
|
||||||
|
|
||||||
Read only the reference for the resolved flow. Capture `git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
Read only the reference for the resolved flow. Capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
||||||
|
|
||||||
## Step 2 — Scope
|
## Step 2 — Scope
|
||||||
|
|
||||||
@@ -62,4 +62,4 @@ Invoke `agent-audit` on each file written and resolve every FAIL before reportin
|
|||||||
|
|
||||||
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest.
|
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest.
|
||||||
|
|
||||||
**Commit verification.** Once the audit is clean, run `git add` and `git commit` — do not stop at staging. Re-run `git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed.
|
**Commit verification.** Once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Audit a skill directory against the agentskills.io specification and the house c
|
|||||||
|
|
||||||
`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words).
|
`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words).
|
||||||
|
|
||||||
Alongside those it runs four shape checks that are not length measurements at all. Two are FAILs: every routing target named in the description — in the compressed `Not <thing> -> <name>` arrow **and** in the prose form — must resolve to a real skill or agent, and every `references/<file>.md` the body names must exist on disk. Three are SUGGESTIONs: a missing boundary clause, a Gotchas section over five entries, and a Gotchas section over 25% of the body. The resolution universe for boundary targets is derived by walking up from the audited `SKILL.md` — the authoring root above it, its own apm package, and that package's declared `apm.yml` dependencies — so a fresh clone and a machine that has run `apm install` return the same verdict. When no universe can be determined the check prints `INFO ... DID NOT RUN` and does not silently pass.
|
Alongside those it runs shape checks that are not length measurements at all. Three are FAILs: every routing target named in the description — in the compressed `Not <thing> -> <name>` arrow **and** in the prose form — must resolve to a real skill or agent; every `references/<file>.md` the body names must exist on disk; and `metadata.version` must be present and three-part semver (ADR-0022). That last one is FAIL rather than SUGGESTION because the `skill-frontmatter` pre-commit hook rejects the file without it — an audit grading it lower would report ready-to-ship on a file the commit gate refuses. Three are SUGGESTIONs: a missing boundary clause, a Gotchas section over five entries, and a Gotchas section over 25% of the body. The resolution universe for boundary targets is derived by walking up from the audited `SKILL.md` — the authoring root above it, its own apm package, and that package's declared `apm.yml` dependencies — so a fresh clone and a machine that has run `apm install` return the same verdict. When no universe can be determined the check prints `INFO ... DID NOT RUN` and does not silently pass.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ Provide the path to the skill directory to audit when invoking.
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `SKILL.md` | Skill instructions for agents |
|
| `SKILL.md` | Skill instructions for agents |
|
||||||
| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description presence and length, body-only word count, line and whole-file word ceilings, boundary-clause presence, boundary-target resolution, `references/` pointer existence, Gotchas entry count and body share, placeholder detection, script executable bit, and interactive-prompt detection |
|
| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description presence and length, `metadata.version` presence and semver shape (ADR-0022), body-only word count, line and whole-file word ceilings, boundary-clause presence, boundary-target resolution, `references/` pointer existence, Gotchas entry count and body share, placeholder detection, script executable bit, and interactive-prompt detection |
|
||||||
| `scripts/validate-provenance.sh` | Provenance validator — checks sources.md completeness, source_keys/slug consistency, Contributing files existence, bidirectional linkage, Research doc: fields, and upstream research doc alignment |
|
| `scripts/validate-provenance.sh` | Provenance validator — checks sources.md completeness, source_keys/slug consistency, Contributing files existence, bidirectional linkage, Research doc: fields, upstream research doc alignment, and (check 9, INFO only) whether a slug's `Description` or `Contributing files` text has changed since a base ref — `--base-ref=<ref>` or `VALIDATE_PROVENANCE_BASE_REF`, defaulting to the merge base with `origin/main` |
|
||||||
| `scripts/vale-wrap.sh` | Vale prefilter wrapper — runs the bundled `Kyberforge` Vale styles against SKILL.md and reports alerts as deterministic FAILs ahead of Step 3's qualitative review |
|
| `scripts/vale-wrap.sh` | Vale prefilter wrapper — runs the bundled `Kyberforge` Vale styles against SKILL.md and reports alerts as deterministic FAILs ahead of Step 3's qualitative review |
|
||||||
| `assets/vale/.vale.ini` | Vale configuration — points Vale at the bundled `Kyberforge` style path, self-located relative to `vale-wrap.sh` |
|
| `assets/vale/.vale.ini` | Vale configuration — points Vale at the bundled `Kyberforge` style path, self-located relative to `vale-wrap.sh` |
|
||||||
| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Vale rule — flags composition and architecture notes in a description (e.g. "cross-cutting", "entry point", "rather than duplicating") |
|
| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Vale rule — flags composition and architecture notes in a description (e.g. "cross-cutting", "entry point", "rather than duplicating") |
|
||||||
@@ -41,7 +41,7 @@ Provide the path to the skill directory to audit when invoking.
|
|||||||
| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed |
|
| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed |
|
||||||
| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift |
|
| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift |
|
||||||
| `references/formatting-and-scripts.md` | Rubric for the formatting and scripts dimensions — heading and fencing conventions, and the agentic-use criteria for bundled scripts |
|
| `references/formatting-and-scripts.md` | Rubric for the formatting and scripts dimensions — heading and fencing conventions, and the agentic-use criteria for bundled scripts |
|
||||||
| `references/validation-scripts.md` | Step 1 troubleshooting — the manual structural fallback when `validate.sh` cannot run, and the script exit codes that are easy to misread (loaded only on a script failure) |
|
| `references/validation-scripts.md` | Step 1 troubleshooting — the manual structural fallback when `validate.sh` cannot run, and the script exit codes that are easy to misread (loaded on a script failure, and on any exit-0 run that printed something — `validate-provenance.sh`'s check 9 is INFO-only, so its findings arrive that way) |
|
||||||
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
||||||
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
||||||
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
skill-author.
|
skill-author.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
@@ -36,9 +36,9 @@ bash scripts/vale-wrap.sh <skill-dir>/SKILL.md
|
|||||||
|
|
||||||
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation.
|
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation.
|
||||||
|
|
||||||
If any of the three cannot run, or exits non-zero for a reason other than findings, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes. Ordinary content FAILs are the expected outcome here and need no fallback.
|
Read `references/validation-scripts.md` when any of the three cannot run or exits non-zero for a reason other than findings, **and whenever `validate-provenance.sh` exits 0 having printed anything**. Ordinary content FAILs are the expected outcome here and need no fallback.
|
||||||
|
|
||||||
`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a usage or environment error, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason. Never grade an exit 2 as a clean pass: empty stdout there means nothing was checked, not that nothing was wrong.
|
`validate-provenance.sh` reports through exit code **and** output; neither alone is the verdict. **0, silent** is a genuine pass. **0 with output** is INFO-only findings — still a `### Provenance` dimension; `references/validation-scripts.md` says what each obliges — for a check-9 INFO, reading rather than relaying. **1** is FAILs plus any INFOs; it emits Why and Fix itself — surface those verbatim. **2** means it never ran — a usage or environment error, reason on stderr, often no stdout — so report `### Provenance` unverified and quote that reason. Never grade an exit 2, or an exit 0 that printed, as a clean pass.
|
||||||
|
|
||||||
`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment:
|
`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment:
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ source_keys:
|
|||||||
|
|
||||||
# Validation Scripts Reference
|
# Validation Scripts Reference
|
||||||
|
|
||||||
Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting.
|
Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting —
|
||||||
Nothing here is needed on a clean run.
|
including `validate-provenance.sh` exiting **0 having printed something**, which is INFO findings,
|
||||||
|
not a clean run. Its silent exit 0 is the only outcome that needs nothing here.
|
||||||
|
|
||||||
## Report the gap, do not guess
|
## Report the gap, do not guess
|
||||||
|
|
||||||
@@ -106,18 +107,33 @@ Three ways to read the result wrong:
|
|||||||
`python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it
|
`python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it
|
||||||
as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.
|
as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.
|
||||||
- **A check-9 INFO — `'<field>' changed for '<slug>' since <ref>` — means go read, not just relay.**
|
- **A check-9 INFO — `'<field>' changed for '<slug>' since <ref>` — means go read, not just relay.**
|
||||||
Check 9 diffs the current `references/sources.md` against a base ref (default: the merge base with
|
Check 9 diffs the current `references/sources.md` against a base ref and flags a slug whose
|
||||||
`origin/main`) and flags a slug whose `Description` or `Contributing files` text differs. It is
|
`Description` or `Contributing files` text differs. It is structurally incapable of telling you
|
||||||
structurally incapable of telling you whether the new wording is still *true* — it only detects
|
whether the new wording is still *true* — it only detects that the text changed — so when this
|
||||||
that the text changed — so when this INFO fires, open the Contributing files it names and the
|
INFO fires, open that slug's own entry: the document named in its `Research doc:` field, and the
|
||||||
document named in that slug's `Research doc:` field, and confirm by reading whether the (possibly
|
files its `Contributing files` list names. Read whichever the changed field is a claim *about* —
|
||||||
|
a Description-only change often leaves the file list untouched, so "open the Contributing files"
|
||||||
|
is where to look, not proof that they are what moved. Confirm by reading whether the (possibly
|
||||||
strengthened) claim genuinely holds. This is the one provenance finding this script cannot verify
|
strengthened) claim genuinely holds. This is the one provenance finding this script cannot verify
|
||||||
for you: every other check here is a structural fact you can relay as-is, but check 9's job is
|
for you: every other check here is a structural fact you can relay as-is, but check 9's job is
|
||||||
only to tell you *where* to spend that reading effort, not to replace it. Acknowledging the INFO
|
only to tell you *where* to spend that reading effort, not to replace it. Acknowledging the INFO
|
||||||
without opening those files is not auditing it. A single INFO naming "no base ref could be
|
without opening those files is not auditing it. Its companion — `'<field>' removed for '<slug>'
|
||||||
resolved" or "no repo root above the skill directory" is the same graceful-skip pattern as every
|
since <ref>` — is the same obligation in the other direction: a claim withdrawn rather than
|
||||||
other check here that cannot run — treat it as an unaudited dimension for that reason, not as a
|
rewritten. No other check here requires the field, so confirm the removal was deliberate.
|
||||||
finding about the skill.
|
- **The check-9 base ref defaults to `git merge-base HEAD origin/main`, and there are two ways to
|
||||||
|
override it.** `--base-ref=<ref>` on the command line, or the `VALIDATE_PROVENANCE_BASE_REF`
|
||||||
|
environment variable; the flag wins when both are given, including when it is given empty
|
||||||
|
(`--base-ref=`), which selects the default resolution and ignores the environment. Reach for one
|
||||||
|
on a fork, a long-lived branch, or a mirror whose remote is not called `origin` — and when a
|
||||||
|
review asks what changed since a specific commit rather than since the branch point.
|
||||||
|
- **A single check-9 INFO naming a whole-check skip is an unaudited dimension, not a finding about
|
||||||
|
the skill.** There are three: "no repo root above the skill directory", "no base ref could be
|
||||||
|
resolved", and "`<path>` is not tracked at `<ref>`". The third is the one to read carefully — it
|
||||||
|
fires when the base ref resolved but `git show <ref>:<path>` did not, which covers both a
|
||||||
|
genuinely new `sources.md` (nothing to flag) and a path git does not know under that name: a
|
||||||
|
renamed skill directory, or an installed, gitignored copy such as a deployed `.claude/skills/`
|
||||||
|
tree. Auditing the deployed copy silently checks nothing; re-run against the authoring path under
|
||||||
|
`plugins/*/.apm/skills/`.
|
||||||
- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full
|
- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full
|
||||||
Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is
|
Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is
|
||||||
scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted.
|
scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ Arguments:
|
|||||||
a long-lived branch, a mirror with a different remote name).
|
a long-lived branch, a mirror with a different remote name).
|
||||||
The VALIDATE_PROVENANCE_BASE_REF environment variable is an
|
The VALIDATE_PROVENANCE_BASE_REF environment variable is an
|
||||||
equivalent, lower-precedence way to set it — the flag wins
|
equivalent, lower-precedence way to set it — the flag wins
|
||||||
if both are given.
|
if both are given, including when the flag is given empty
|
||||||
|
(\`--base-ref=\`), which selects the default resolution.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 All checks passed (or nothing to validate)
|
0 All checks passed (or nothing to validate)
|
||||||
@@ -47,10 +48,13 @@ Checks performed:
|
|||||||
8 Extracted non-(none) slug in research doc present in sources.md
|
8 Extracted non-(none) slug in research doc present in sources.md
|
||||||
9 Description or Contributing files text changed since --base-ref (INFO
|
9 Description or Contributing files text changed since --base-ref (INFO
|
||||||
only — a bash script cannot verify the claim is still TRUE, only that it
|
only — a bash script cannot verify the claim is still TRUE, only that it
|
||||||
changed; the auditor reads the named files to check that). A slug absent
|
changed; the auditor reads the named files to check that). Wrapped values
|
||||||
at the base ref is a creation, not a change, and is not flagged. When the
|
are joined before comparison, so a re-wrap alone is not a change and a
|
||||||
base ref cannot be resolved at all, this is announced as ONE INFO for the
|
rewrite of any line of one is. A slug absent at the base ref is a
|
||||||
whole check, never a silent skip.
|
creation, not a change, and is not flagged; a field that WAS there and is
|
||||||
|
now gone is announced as a removal. When the base ref cannot be resolved,
|
||||||
|
or references/sources.md is not tracked under this path at that ref, this
|
||||||
|
is announced as ONE INFO for the whole check, never a silent skip.
|
||||||
|
|
||||||
Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE
|
Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE
|
||||||
INDEX — a file whose basename is sources.md, whose H2 headings ARE source
|
INDEX — a file whose basename is sources.md, whose H2 headings ARE source
|
||||||
@@ -70,8 +74,15 @@ fi
|
|||||||
# never counts against them — a caller passing it alongside skill-dir sees
|
# never counts against them — a caller passing it alongside skill-dir sees
|
||||||
# the same argument-count behaviour as one who does not pass it at all, and a
|
# the same argument-count behaviour as one who does not pass it at all, and a
|
||||||
# genuinely extra positional argument is still rejected.
|
# genuinely extra positional argument is still rejected.
|
||||||
|
#
|
||||||
|
# BASE_REF_OVERRIDE is deliberately left UNSET here rather than initialised to
|
||||||
|
# the empty string. `--base-ref=` (given, but empty) and "no flag at all" are
|
||||||
|
# different instructions — the first says "use the default resolution, ignoring
|
||||||
|
# the environment", the second says "fall back to the environment" — and an
|
||||||
|
# empty-string initialiser collapsed them: `${BASE_REF_OVERRIDE:-$ENV}` treats
|
||||||
|
# an empty flag value as absent, so the environment variable won and the usage
|
||||||
|
# text's "the flag wins if both are given" was false for exactly that spelling.
|
||||||
declare -a POSITIONAL_ARGS=()
|
declare -a POSITIONAL_ARGS=()
|
||||||
BASE_REF_OVERRIDE=""
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--base-ref=*)
|
--base-ref=*)
|
||||||
@@ -107,10 +118,15 @@ fi
|
|||||||
|
|
||||||
SKILL_DIR_ARG="${POSITIONAL_ARGS[0]}"
|
SKILL_DIR_ARG="${POSITIONAL_ARGS[0]}"
|
||||||
|
|
||||||
# The flag wins over the environment variable when both are given; either is
|
# The flag wins over the environment variable whenever the flag was GIVEN —
|
||||||
# empty-string when unset, and an empty string tells the Python body to fall
|
# `+x` tests for presence, not for a non-empty value, which is the distinction
|
||||||
|
# `:-` could not make. An empty result either way tells the Python body to fall
|
||||||
# back to `git merge-base HEAD origin/main`.
|
# back to `git merge-base HEAD origin/main`.
|
||||||
BASE_REF="${BASE_REF_OVERRIDE:-${VALIDATE_PROVENANCE_BASE_REF:-}}"
|
if [[ -n "${BASE_REF_OVERRIDE+x}" ]]; then
|
||||||
|
BASE_REF="$BASE_REF_OVERRIDE"
|
||||||
|
else
|
||||||
|
BASE_REF="${VALIDATE_PROVENANCE_BASE_REF:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
# python3 is a HARD dependency. Without this preflight a missing interpreter
|
# python3 is a HARD dependency. Without this preflight a missing interpreter
|
||||||
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
|
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
|
||||||
@@ -491,10 +507,21 @@ def find_repo_root(start_dir):
|
|||||||
# --- Check 9 helpers ---------------------------------------------------
|
# --- Check 9 helpers ---------------------------------------------------
|
||||||
# Check 9 needs a raw field VALUE (as text, to diff against an earlier
|
# Check 9 needs a raw field VALUE (as text, to diff against an earlier
|
||||||
# version), not the parsed structure parse_contributing_files() and
|
# version), not the parsed structure parse_contributing_files() and
|
||||||
# parse_status() return — a Contributing files list that reordered its
|
# parse_status() return. The ONE normalization applied is whitespace
|
||||||
# entries without changing them is not what this check is looking for, but
|
# collapsing, which is what makes a re-wrap or a re-indent invisible; nothing
|
||||||
# neither is normalizing so hard that a genuine rewrite disappears. Raw text,
|
# else is normalized away.
|
||||||
# whitespace-normalized, is the middle ground.
|
#
|
||||||
|
# In particular a REORDERED Contributing files list DOES fire this check, and
|
||||||
|
# that is deliberate — the header here used to claim the opposite, which the
|
||||||
|
# code never did. Order-insensitivity cannot be had for one field without
|
||||||
|
# distorting the other: the two fields share this parser, and the only way to
|
||||||
|
# ignore order is to split the value into items and sort them, which for a
|
||||||
|
# prose Description means splitting on commas and would then hide a genuine
|
||||||
|
# rewrite that merely permuted its clauses. Check 9 is always an INFO whose
|
||||||
|
# whole job is to point a human at a place to read; a reordered list costs
|
||||||
|
# that human one glance to dismiss, whereas a hidden rewrite is the exact
|
||||||
|
# failure #118 exists to catch. False positive over false negative, on this
|
||||||
|
# check, on purpose.
|
||||||
|
|
||||||
def run_git(args, cwd):
|
def run_git(args, cwd):
|
||||||
"""Run `git <args>` in cwd. Returns (returncode, stdout, stderr) — never
|
"""Run `git <args>` in cwd. Returns (returncode, stdout, stderr) — never
|
||||||
@@ -523,14 +550,44 @@ def find_slug_block(content, slug):
|
|||||||
m = pattern.search(content)
|
m = pattern.search(content)
|
||||||
return m.group(1) if m else None
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
# A field value ENDS at the next field, the next heading, or a blank line.
|
||||||
|
# Every other non-blank line is a continuation of the value the author wrapped
|
||||||
|
# across physical lines.
|
||||||
|
#
|
||||||
|
# This boundary is what the old `(.+)$` regex did not have. `.` does not cross
|
||||||
|
# a newline, so only the FIRST physical line of a wrapped value was ever
|
||||||
|
# compared — and a rewrite confined to a continuation line produced no finding
|
||||||
|
# at all. That is verbatim the hedge-to-confident-claim regression #118 exists
|
||||||
|
# to catch, invisible to the check written to catch it. The bullet branch had
|
||||||
|
# the same defect one level down: a wrapped bullet's continuation does not
|
||||||
|
# start with '- ', so the loop broke there and silently dropped every
|
||||||
|
# remaining bullet.
|
||||||
|
#
|
||||||
|
# A continuation line that itself opens with bold text ('**note** — ...') is
|
||||||
|
# read as a boundary and truncates the value. That is a known, narrow
|
||||||
|
# false-negative, accepted because the alternative — no boundary at all —
|
||||||
|
# is what produced the wide one above.
|
||||||
|
FIELD_BOUNDARY_RE = re.compile(r'^(?:- )?\*\*|^#{1,6} ')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_field_boundary(stripped_line):
|
||||||
|
"""True when a stripped line starts a new field, bullet-less heading or H2."""
|
||||||
|
return bool(FIELD_BOUNDARY_RE.match(stripped_line))
|
||||||
|
|
||||||
|
|
||||||
def parse_field_raw(content, slug, field_name):
|
def parse_field_raw(content, slug, field_name):
|
||||||
"""Raw text of a '**<field_name>:**' field under a slug H2.
|
"""Raw text of a '**<field_name>:**' field under a slug H2, wrapping joined.
|
||||||
|
|
||||||
Mirrors the two authored shapes parse_contributing_files() and
|
Mirrors the two authored shapes parse_contributing_files() and
|
||||||
parse_status() already handle (inline value on the same line, or a
|
parse_status() already handle (inline value on the same line, or a
|
||||||
bare heading followed by '- ' bullets), but returns text rather than a
|
bare heading followed by '- ' bullets), but returns text rather than a
|
||||||
parsed structure, because check 9 diffs wording, not semantics.
|
parsed structure, because check 9 diffs wording, not semantics.
|
||||||
|
|
||||||
|
Continuation lines are joined into the value they belong to before the
|
||||||
|
caller normalizes and compares, so a value wrapped across two lines and
|
||||||
|
the same value on one line are the same text — and a change made on any
|
||||||
|
line of a wrapped value is visible, not just one made on the first.
|
||||||
|
|
||||||
Returns None when the H2 itself is absent (the slug did not exist at
|
Returns None when the H2 itself is absent (the slug did not exist at
|
||||||
this content's revision) or the field is absent — both read as "no
|
this content's revision) or the field is absent — both read as "no
|
||||||
earlier claim to compare against" to the caller, which is deliberate:
|
earlier claim to compare against" to the caller, which is deliberate:
|
||||||
@@ -539,28 +596,49 @@ def parse_field_raw(content, slug, field_name):
|
|||||||
block = find_slug_block(content, slug)
|
block = find_slug_block(content, slug)
|
||||||
if block is None:
|
if block is None:
|
||||||
return None
|
return None
|
||||||
inline_re = re.compile(r'^\- \*\*' + re.escape(field_name) + r':\*\* (.+)$', re.MULTILINE)
|
lines = block.splitlines()
|
||||||
im = inline_re.search(block)
|
inline_re = re.compile(r'^\- \*\*' + re.escape(field_name) + r':\*\*[ \t]*(.*)$')
|
||||||
|
heading_re = re.compile(r'^\*\*' + re.escape(field_name) + r':\*\*[ \t]*$')
|
||||||
|
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
im = inline_re.match(line)
|
||||||
if im:
|
if im:
|
||||||
return im.group(1).strip()
|
parts = [im.group(1).strip()]
|
||||||
heading_re = re.compile(r'^\*\*' + re.escape(field_name) + r':\*\*\s*$', re.MULTILINE)
|
for cont in lines[idx + 1:]:
|
||||||
hm = heading_re.search(block)
|
stripped = cont.strip()
|
||||||
if not hm:
|
if not stripped or stripped.startswith("- ") or _is_field_boundary(stripped):
|
||||||
return None
|
break
|
||||||
lines = []
|
parts.append(stripped)
|
||||||
for line in block[hm.end():].splitlines():
|
joined = " ".join(p for p in parts if p).strip()
|
||||||
line = line.strip()
|
return joined or None
|
||||||
if not line:
|
if heading_re.match(line):
|
||||||
if lines:
|
entries = []
|
||||||
|
for cont in lines[idx + 1:]:
|
||||||
|
stripped = cont.strip()
|
||||||
|
if not stripped:
|
||||||
|
if entries:
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
if not line.startswith("- "):
|
if _is_field_boundary(stripped):
|
||||||
break
|
break
|
||||||
lines.append(line[2:].strip())
|
if stripped.startswith("- "):
|
||||||
return ", ".join(lines) if lines else None
|
entries.append(stripped[2:].strip())
|
||||||
|
elif entries:
|
||||||
|
# A wrapped bullet: fold it back into the bullet it
|
||||||
|
# continues rather than ending the list here.
|
||||||
|
entries[-1] = (entries[-1] + " " + stripped).strip()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return ", ".join(e for e in entries if e) or None
|
||||||
|
return None
|
||||||
|
|
||||||
def normalize_field_text(value):
|
def normalize_field_text(value):
|
||||||
"""Collapse whitespace so reformatting alone never registers as a change."""
|
"""Collapse whitespace so reformatting alone never registers as a change.
|
||||||
|
|
||||||
|
True only because parse_field_raw() joins wrapped continuation lines
|
||||||
|
first: collapsing whitespace inside a value that had already been
|
||||||
|
truncated at its first newline normalized nothing a re-wrap could change.
|
||||||
|
"""
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
findings = []
|
findings = []
|
||||||
@@ -1025,28 +1103,82 @@ else:
|
|||||||
["show", f"{resolved_base_ref}:{sources_md_relpath}"], repo_root
|
["show", f"{resolved_base_ref}:{sources_md_relpath}"], repo_root
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
# The base ref resolved fine, but references/sources.md did not
|
# The base ref resolved fine but `git show <ref>:<path>` did not.
|
||||||
# exist there at all — the whole file is new. Every entry in it
|
# That single return code covers two situations this check cannot
|
||||||
# is therefore a creation, not a change: nothing to flag, and
|
# tell apart, and only one of them is harmless:
|
||||||
# this is not a structural failure of the check, so no INFO
|
#
|
||||||
# either. Same reasoning applies per-slug below when the ref
|
# the file genuinely did not exist at the base ref — the whole
|
||||||
# resolved but a given '## <slug>' heading did not exist yet.
|
# sources.md is new, every entry in it is a creation, and there
|
||||||
|
# is nothing check 9 could have flagged;
|
||||||
|
#
|
||||||
|
# the path is not TRACKED under that name at the base ref — a
|
||||||
|
# renamed skill directory, or a copy of the skill living
|
||||||
|
# somewhere untracked or gitignored (an installed .claude/skills
|
||||||
|
# tree is the everyday case).
|
||||||
|
#
|
||||||
|
# Treating both as "creation, nothing to flag" made the second one
|
||||||
|
# a silent, whole-skill skip: the same directory audited at its
|
||||||
|
# authoring path reported changed claims and at its deployed path
|
||||||
|
# reported nothing, with no way to tell that from a clean run.
|
||||||
|
# That is the exact fail-open this script's own header forbids —
|
||||||
|
# "never a silent skip" — so announce it once for the whole check
|
||||||
|
# and hand over git's own stderr, which is the only diagnostic
|
||||||
|
# that separates the two cases.
|
||||||
|
detail = show_err.strip().splitlines()
|
||||||
|
detail = detail[0] if detail else "git gave no reason"
|
||||||
|
emit_info(
|
||||||
|
f"Check 9 skipped — '{sources_md_relpath}' is not tracked at {resolved_base_ref}",
|
||||||
|
"references/sources.md",
|
||||||
|
f"`git show {resolved_base_ref}:{sources_md_relpath}` failed ({detail}). "
|
||||||
|
f"Either the file did not exist at that ref — in which case every entry is a "
|
||||||
|
f"creation and there was nothing to flag — or this path is not tracked under "
|
||||||
|
f"that name there: a renamed skill directory, or an untracked or gitignored copy "
|
||||||
|
f"of the skill such as a deployed .claude/skills/ tree. "
|
||||||
|
f"Check 9 did not run for any slug in this skill. "
|
||||||
|
f"Re-run against the tracked authoring path, or pass --base-ref=<ref> naming a "
|
||||||
|
f"commit where this path exists."
|
||||||
|
)
|
||||||
old_sources_content = None
|
old_sources_content = None
|
||||||
|
|
||||||
if old_sources_content is not None:
|
if old_sources_content is not None:
|
||||||
for slug in unique_slugs:
|
for slug in unique_slugs:
|
||||||
changed_fields = []
|
changed_fields = []
|
||||||
|
removed_fields = []
|
||||||
for field_name in ("Description", "Contributing files"):
|
for field_name in ("Description", "Contributing files"):
|
||||||
old_value = parse_field_raw(old_sources_content, slug, field_name)
|
old_value = parse_field_raw(old_sources_content, slug, field_name)
|
||||||
new_value = parse_field_raw(sources_content, slug, field_name)
|
new_value = parse_field_raw(sources_content, slug, field_name)
|
||||||
if old_value is None or new_value is None:
|
if old_value is None and new_value is None:
|
||||||
|
continue
|
||||||
|
if old_value is None:
|
||||||
# No earlier claim to compare against — a brand-new
|
# No earlier claim to compare against — a brand-new
|
||||||
# entry, or a field that did not exist yet at the
|
# entry, or a field that did not exist yet at the
|
||||||
# base ref. That is a creation, not a change, and is
|
# base ref. That is a creation, not a change, and is
|
||||||
# never flagged.
|
# never flagged.
|
||||||
continue
|
continue
|
||||||
|
if new_value is None:
|
||||||
|
# The field existed at the base ref and is gone now.
|
||||||
|
# This was folded into the creation skip above, which
|
||||||
|
# justified only the other half: deleting a whole
|
||||||
|
# '- **Description:**' line left NO finding anywhere —
|
||||||
|
# no other check in this script requires the field, so
|
||||||
|
# a claim could be removed as invisibly as it could be
|
||||||
|
# strengthened. Announce it; the auditor decides
|
||||||
|
# whether the removal was intended.
|
||||||
|
removed_fields.append(field_name)
|
||||||
|
continue
|
||||||
if normalize_field_text(old_value) != normalize_field_text(new_value):
|
if normalize_field_text(old_value) != normalize_field_text(new_value):
|
||||||
changed_fields.append(field_name)
|
changed_fields.append(field_name)
|
||||||
|
if removed_fields:
|
||||||
|
removed_list = " and ".join(removed_fields)
|
||||||
|
emit_info(
|
||||||
|
f"'{removed_list}' removed for '{slug}' since {resolved_base_ref}",
|
||||||
|
f"references/sources.md (## {slug})",
|
||||||
|
f"The '## {slug}' entry had {removed_list} at {resolved_base_ref} and has "
|
||||||
|
f"none now. Nothing else in this script requires the field, so the removal "
|
||||||
|
f"is otherwise invisible. Confirm it was deliberate — a provenance claim "
|
||||||
|
f"withdrawn is as much a change to the chain as one rewritten — and "
|
||||||
|
f"restore the field if it was lost to an edit."
|
||||||
|
)
|
||||||
if changed_fields:
|
if changed_fields:
|
||||||
field_list = " and ".join(changed_fields)
|
field_list = " and ".join(changed_fields)
|
||||||
emit_info(
|
emit_info(
|
||||||
|
|||||||
@@ -1289,6 +1289,50 @@ else:
|
|||||||
if desc:
|
if desc:
|
||||||
ok("description has no unfilled placeholders")
|
ok("description has no unfilled placeholders")
|
||||||
|
|
||||||
|
# --- ADR-0022: metadata.version is mandatory -------------------------------
|
||||||
|
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
|
||||||
|
# `.pre-commit-config.yaml`'s `skill-frontmatter` hook REJECTS a SKILL.md with
|
||||||
|
# no `metadata.version`, and rejects a value that is not three-part semver.
|
||||||
|
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
|
||||||
|
# tier below FAIL lets that step report done on a skill the commit gate then
|
||||||
|
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
|
||||||
|
# below warns about, arrived at from the other direction. Verified before this
|
||||||
|
# check existed: a SKILL.md with no `metadata:` block at all reported "All
|
||||||
|
# checks passed".
|
||||||
|
#
|
||||||
|
# The rule is DUPLICATED from that hook for the same cache-isolation reason as
|
||||||
|
# every other constant here — an installed plugin's scripts cannot read the
|
||||||
|
# repo-root config. Keep the two in step: this check must accept exactly what
|
||||||
|
# the hook accepts.
|
||||||
|
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
|
||||||
|
|
||||||
|
try:
|
||||||
|
fm_data = yaml.safe_load(fm)
|
||||||
|
except Exception:
|
||||||
|
# Unreachable in practice: description_value() above parses the same text
|
||||||
|
# and hard-exits on a YAML error, so anything arriving here already parsed.
|
||||||
|
fm_data = None
|
||||||
|
metadata_block = fm_data.get('metadata') if isinstance(fm_data, dict) else None
|
||||||
|
|
||||||
|
if not isinstance(metadata_block, dict) or metadata_block.get('version') is None:
|
||||||
|
fail("frontmatter has no metadata.version — ADR-0022 makes it mandatory for "
|
||||||
|
"every skill, and the skill-frontmatter pre-commit hook rejects the file "
|
||||||
|
"without it. Add `metadata:` / ` version: \"1.0.0\"` (new skills start "
|
||||||
|
"at \"0.1.0\")")
|
||||||
|
else:
|
||||||
|
version_value = metadata_block['version']
|
||||||
|
# NOT str()-coerced blind: `version: 1.0` is a YAML float, and its "1.0"
|
||||||
|
# spelling is exactly the two-part value the hook rejects — coercing and
|
||||||
|
# then matching keeps this check and the hook agreeing on that case.
|
||||||
|
version_text = version_value if isinstance(version_value, str) else str(version_value)
|
||||||
|
version_text = version_text.strip()
|
||||||
|
if SEMVER_RE.match(version_text):
|
||||||
|
ok(f"metadata.version present: '{version_text}' (ADR-0022)")
|
||||||
|
else:
|
||||||
|
fail(f"metadata.version '{version_text}' is not three-part semver — the "
|
||||||
|
f"skill-frontmatter pre-commit hook rejects it. Use MAJOR.MINOR.PATCH, "
|
||||||
|
f"e.g. \"1.0.0\"")
|
||||||
|
|
||||||
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
||||||
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
||||||
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
||||||
@@ -1515,11 +1559,66 @@ def stdin_redirected(line, prev_line):
|
|||||||
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
|
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
|
||||||
return '<' in unquoted or prev_line.rstrip().endswith('|')
|
return '<' in unquoted or prev_line.rstrip().endswith('|')
|
||||||
|
|
||||||
|
# A here-doc body is DATA, not command position. Every script in this corpus
|
||||||
|
# carries a `usage() { cat <<EOF ... EOF; }`, and prose wrapped inside one puts
|
||||||
|
# ordinary English at the start of a line — "read is reported as an INFO ..."
|
||||||
|
# in this skill's own validate-provenance.sh, which made skill-audit hard-FAIL
|
||||||
|
# on its own script. Reflowing that one sentence would have cleared the finding
|
||||||
|
# and left the cause: every future usage text is one wrap away from the same
|
||||||
|
# false positive, and the remedy an author reaches for is contorting working
|
||||||
|
# source, which the note above records has already happened twice.
|
||||||
|
#
|
||||||
|
# Detection is deliberately conservative in the direction that matters. A
|
||||||
|
# here-doc body is skipped only when its terminator is actually found further
|
||||||
|
# down the file; an opener with no terminator — the shape a stray `<<` inside a
|
||||||
|
# string would produce — is ignored rather than allowed to swallow the tail,
|
||||||
|
# because swallowing the tail is a false NEGATIVE and this check exists to fail
|
||||||
|
# closed. `<<<` here-strings open nothing and are excluded by the lookbehind.
|
||||||
|
HEREDOC_START_RE = re.compile(r'(?<!<)<<-?\s*(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1')
|
||||||
|
|
||||||
|
|
||||||
|
def heredoc_delimiter(line):
|
||||||
|
"""The here-doc terminator this line opens, or None."""
|
||||||
|
m = HEREDOC_START_RE.search(line)
|
||||||
|
return m.group(2) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def heredoc_body_indices(lines):
|
||||||
|
"""Line indices that are here-doc BODY (plus its terminator), not code."""
|
||||||
|
skip = set()
|
||||||
|
i, n = 0, len(lines)
|
||||||
|
while i < n:
|
||||||
|
stripped = lines[i].strip()
|
||||||
|
delim = None if stripped.startswith('#') else heredoc_delimiter(lines[i])
|
||||||
|
if delim:
|
||||||
|
# `<<-` allows an indented terminator, so compare stripped.
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
if lines[j].strip() == delim:
|
||||||
|
skip.update(range(i + 1, j + 1))
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
return skip
|
||||||
|
|
||||||
|
|
||||||
|
# The here-doc exemption applies to the `read` heuristic ONLY, and the
|
||||||
|
# asymmetry is the point. `read` is an ordinary English verb, so any prose a
|
||||||
|
# script prints is one line-wrap away from opening with it. `input(` is not a
|
||||||
|
# word — a line beginning `input(` inside a here-doc is an embedded Python
|
||||||
|
# program pausing for a keypress, which is exactly what this check is for, and
|
||||||
|
# these scripts embed Python in a here-doc as a matter of course. Exempting the
|
||||||
|
# whole body would have disarmed the check across every script in the corpus.
|
||||||
def interactive_reads(source):
|
def interactive_reads(source):
|
||||||
hits = []
|
hits = []
|
||||||
prev_line = ''
|
prev_line = ''
|
||||||
for line in source.splitlines():
|
lines = source.splitlines()
|
||||||
|
in_heredoc = heredoc_body_indices(lines)
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
if idx in in_heredoc:
|
||||||
|
if re.match(r'input\(', stripped):
|
||||||
|
hits.append(stripped)
|
||||||
|
continue
|
||||||
if re.match(r'read(\s|$)', stripped):
|
if re.match(r'read(\s|$)', stripped):
|
||||||
if not stdin_redirected(line, prev_line):
|
if not stdin_redirected(line, prev_line):
|
||||||
hits.append(stripped)
|
hits.append(stripped)
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ EOF
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Helper: a sources.md whose Description is wrapped across two physical
|
||||||
|
# lines — the shape check 9's parser used to truncate at the first newline.
|
||||||
|
make_wrapped_sources_md() {
|
||||||
|
local dir="$1"
|
||||||
|
mkdir -p "$dir/references"
|
||||||
|
cat > "$dir/references/sources.md" <<'EOF'
|
||||||
|
# Sources
|
||||||
|
|
||||||
|
## my-source
|
||||||
|
|
||||||
|
- **URL:** https://example.com/my-source
|
||||||
|
- **Description:** A test source, informing the dispatch table's shape with
|
||||||
|
no forge-specific content drawn directly from it beyond that.
|
||||||
|
- **Contributing files:** SKILL.md
|
||||||
|
- **Research doc:** (none)
|
||||||
|
- **Status:** `extracted`
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
# Helper: turn dir into a real git repo with one commit of its current
|
# Helper: turn dir into a real git repo with one commit of its current
|
||||||
# contents, and a refs/remotes/origin/main pointing at that same commit.
|
# contents, and a refs/remotes/origin/main pointing at that same commit.
|
||||||
# Check 9 diffs the skill's references/sources.md against `git merge-base
|
# Check 9 diffs the skill's references/sources.md against `git merge-base
|
||||||
@@ -1629,23 +1648,247 @@ EOF
|
|||||||
assert_output --partial "Check 9 skipped — no base ref could be resolved"
|
assert_output --partial "Check 9 skipped — no base ref could be resolved"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "check 9: --base-ref overrides the default origin/main resolution" {
|
@test "check 9: --base-ref overrides a default origin/main that resolves to something else" {
|
||||||
local skill="$TMPDIR/my-skill"
|
local skill="$TMPDIR/my-skill"
|
||||||
make_skill_with_source_keys "$skill"
|
make_skill_with_source_keys "$skill"
|
||||||
make_sources_md "$skill"
|
make_sources_md "$skill"
|
||||||
commit_as_base "$skill"
|
commit_as_base "$skill"
|
||||||
local base_sha
|
local old_sha
|
||||||
base_sha="$(git -C "$skill" rev-parse HEAD)"
|
old_sha="$(git -C "$skill" rev-parse HEAD)"
|
||||||
git -C "$skill" update-ref -d refs/remotes/origin/main >/dev/null 2>&1
|
|
||||||
|
|
||||||
|
# A SECOND commit carrying the rewritten claim, with origin/main moved onto
|
||||||
|
# it. The default base ref therefore resolves — to a commit that matches the
|
||||||
|
# working tree — so a silent default run proves there was a default here to
|
||||||
|
# override. Deleting origin/main instead, as this test used to, proved only
|
||||||
|
# that the flag works when nothing else does.
|
||||||
sed -i 's/^- \*\*Description:\*\* A test source\.$/- **Description:** A rewritten claim./' \
|
sed -i 's/^- \*\*Description:\*\* A test source\.$/- **Description:** A rewritten claim./' \
|
||||||
"$skill/references/sources.md"
|
"$skill/references/sources.md"
|
||||||
|
git -C "$skill" -c user.email=test@example.com -c user.name=test commit -aqm rewrite >/dev/null 2>&1
|
||||||
|
git -C "$skill" update-ref refs/remotes/origin/main HEAD >/dev/null 2>&1
|
||||||
|
|
||||||
run bash "$SCRIPT" "$skill" "--base-ref=$base_sha"
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output ""
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill" "--base-ref=$old_sha"
|
||||||
assert_success
|
assert_success
|
||||||
assert_output --partial "'Description' changed for 'my-source'"
|
assert_output --partial "'Description' changed for 'my-source'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "check 9: VALIDATE_PROVENANCE_BASE_REF sets the base ref when no flag is given" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
local old_sha
|
||||||
|
old_sha="$(git -C "$skill" rev-parse HEAD)"
|
||||||
|
sed -i 's/^- \*\*Description:\*\* A test source\.$/- **Description:** A rewritten claim./' \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
git -C "$skill" -c user.email=test@example.com -c user.name=test commit -aqm rewrite >/dev/null 2>&1
|
||||||
|
git -C "$skill" update-ref refs/remotes/origin/main HEAD >/dev/null 2>&1
|
||||||
|
|
||||||
|
run env VALIDATE_PROVENANCE_BASE_REF="$old_sha" bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "'Description' changed for 'my-source'"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: the --base-ref flag wins over the environment variable" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
local old_sha
|
||||||
|
old_sha="$(git -C "$skill" rev-parse HEAD)"
|
||||||
|
sed -i 's/^- \*\*Description:\*\* A test source\.$/- **Description:** A rewritten claim./' \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
git -C "$skill" -c user.email=test@example.com -c user.name=test commit -aqm rewrite >/dev/null 2>&1
|
||||||
|
local new_sha
|
||||||
|
new_sha="$(git -C "$skill" rev-parse HEAD)"
|
||||||
|
|
||||||
|
# The environment names the old commit (which would fire), the flag names
|
||||||
|
# the new one (which would not). The usage text promises the flag wins.
|
||||||
|
run env VALIDATE_PROVENANCE_BASE_REF="$old_sha" bash "$SCRIPT" "$skill" "--base-ref=$new_sha"
|
||||||
|
assert_success
|
||||||
|
assert_output ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: an EMPTY --base-ref is still 'given' and wins over the environment variable" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
local old_sha
|
||||||
|
old_sha="$(git -C "$skill" rev-parse HEAD)"
|
||||||
|
sed -i 's/^- \*\*Description:\*\* A test source\.$/- **Description:** A rewritten claim./' \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
git -C "$skill" -c user.email=test@example.com -c user.name=test commit -aqm rewrite >/dev/null 2>&1
|
||||||
|
git -C "$skill" update-ref refs/remotes/origin/main HEAD >/dev/null 2>&1
|
||||||
|
|
||||||
|
# `--base-ref=` selects the DEFAULT resolution (origin/main, which now
|
||||||
|
# matches the working tree), so nothing fires. Under the old `:-` spelling
|
||||||
|
# the empty value read as absent and the environment variable won, firing
|
||||||
|
# the INFO and contradicting the usage text.
|
||||||
|
run env VALIDATE_PROVENANCE_BASE_REF="$old_sha" bash "$SCRIPT" "$skill" "--base-ref="
|
||||||
|
assert_success
|
||||||
|
assert_output ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cycle 29 — Check 9: a WRAPPED field value. The parser compared only the first
|
||||||
|
# physical line, so a rewrite confined to a continuation line — the exact
|
||||||
|
# hedge-to-confident-claim shape #118 exists to catch — produced no finding at
|
||||||
|
# all, while a pure re-wrap produced a false one.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@test "check 9: a rewrite confined to a wrapped Description's CONTINUATION line fires an INFO" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_wrapped_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
|
||||||
|
# Hedge to confident claim, on the second physical line only. This is the
|
||||||
|
# regression check 9 was written for, and the one it could not see.
|
||||||
|
sed -i "s|^ no forge-specific content drawn directly from it beyond that\.\$| it grounds Step 2's dispatch table in full.|" \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "'Description' changed for 'my-source'"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: re-wrapping a Description with no wording change produces no finding" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_wrapped_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
|
||||||
|
# Same words, different line breaks. normalize_field_text()'s docstring
|
||||||
|
# promises this is invisible; it was not, because the value was truncated
|
||||||
|
# at its first newline before the whitespace collapse ever ran.
|
||||||
|
python3 - "$skill/references/sources.md" <<'PY'
|
||||||
|
import sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
text = open(path).read()
|
||||||
|
old = ("- **Description:** A test source, informing the dispatch table's shape with\n"
|
||||||
|
" no forge-specific content drawn directly from it beyond that.")
|
||||||
|
new = ("- **Description:** A test source, informing the dispatch\n"
|
||||||
|
" table's shape with no forge-specific content drawn\n"
|
||||||
|
" directly from it beyond that.")
|
||||||
|
assert old in text
|
||||||
|
open(path, 'w').write(text.replace(old, new))
|
||||||
|
PY
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: the bullet form of Contributing files is compared, not skipped" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
mkdir -p "$skill/references"
|
||||||
|
cat > "$skill/references/sources.md" <<'EOF'
|
||||||
|
# Sources
|
||||||
|
|
||||||
|
## my-source
|
||||||
|
|
||||||
|
- **URL:** https://example.com/my-source
|
||||||
|
- **Description:** A test source.
|
||||||
|
|
||||||
|
**Contributing files:**
|
||||||
|
- SKILL.md (the dispatch table)
|
||||||
|
- references/other.md (the rubric)
|
||||||
|
|
||||||
|
- **Research doc:** (none)
|
||||||
|
- **Status:** `extracted`
|
||||||
|
EOF
|
||||||
|
printf -- '---\nsource_keys:\n - my-source\n---\n\nnotes\n' > "$skill/references/other.md"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
|
||||||
|
# The change is in the SECOND bullet. The old loop joined bullets in
|
||||||
|
# document order too, but broke on any wrapped one — and no test covered
|
||||||
|
# this branch at all, both existing check-9 tests using the inline form.
|
||||||
|
sed -i 's|^- references/other.md (the rubric)$|- references/other.md (the whole rubric, verbatim)|' \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "'Contributing files' changed for 'my-source'"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: a wrapped bullet does not silently drop the bullets after it" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
mkdir -p "$skill/references"
|
||||||
|
cat > "$skill/references/sources.md" <<'EOF'
|
||||||
|
# Sources
|
||||||
|
|
||||||
|
## my-source
|
||||||
|
|
||||||
|
- **URL:** https://example.com/my-source
|
||||||
|
- **Description:** A test source.
|
||||||
|
|
||||||
|
**Contributing files:**
|
||||||
|
- SKILL.md (the dispatch table, and the gates
|
||||||
|
common to every branch of it)
|
||||||
|
- references/other.md (the rubric)
|
||||||
|
|
||||||
|
- **Research doc:** (none)
|
||||||
|
- **Status:** `extracted`
|
||||||
|
EOF
|
||||||
|
printf -- '---\nsource_keys:\n - my-source\n---\n\nnotes\n' > "$skill/references/other.md"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
|
||||||
|
# The old loop broke at the wrapped continuation line, so everything from
|
||||||
|
# here down was never part of the compared value — a change to the last
|
||||||
|
# bullet was invisible.
|
||||||
|
sed -i 's|^- references/other.md (the rubric)$|- references/other.md (rewritten claim)|' \
|
||||||
|
"$skill/references/sources.md"
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "'Contributing files' changed for 'my-source'"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: a field present at the base ref and deleted since is announced" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_sources_md "$skill"
|
||||||
|
commit_as_base "$skill"
|
||||||
|
|
||||||
|
# No other check in this script requires a Description, so a deleted one
|
||||||
|
# used to leave no finding anywhere: a claim could be withdrawn as
|
||||||
|
# invisibly as it could be strengthened.
|
||||||
|
sed -i '/^- \*\*Description:\*\*/d' "$skill/references/sources.md"
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "'Description' removed for 'my-source'"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "check 9: a sources.md untracked at the base ref is announced, not silently skipped" {
|
||||||
|
local repo="$TMPDIR/repo"
|
||||||
|
local skill="$repo/tracked-skill"
|
||||||
|
mkdir -p "$skill"
|
||||||
|
make_skill_with_source_keys "$skill"
|
||||||
|
make_sources_md "$skill"
|
||||||
|
commit_as_base "$repo"
|
||||||
|
|
||||||
|
# A copy of the same skill at a path git does not know — the everyday case
|
||||||
|
# being an installed, gitignored .claude/skills/ tree. The base ref
|
||||||
|
# resolves fine; `git show <ref>:<path>` does not. Treating that as
|
||||||
|
# "creation, nothing to flag" made the whole check vanish without a word,
|
||||||
|
# so the same directory reported findings at one path and silence at the
|
||||||
|
# other.
|
||||||
|
cp -r "$skill" "$repo/untracked-copy"
|
||||||
|
|
||||||
|
run bash "$SCRIPT" "$repo/untracked-copy"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "INFO"
|
||||||
|
assert_output --partial "is not tracked at"
|
||||||
|
assert_output --partial "Check 9 did not run for any slug in this skill."
|
||||||
|
}
|
||||||
|
|
||||||
@test "check 9: an invalid --base-ref value is reported as unresolvable, not a crash" {
|
@test "check 9: an invalid --base-ref value is reported as unresolvable, not a crash" {
|
||||||
local skill="$TMPDIR/my-skill"
|
local skill="$TMPDIR/my-skill"
|
||||||
make_skill_with_source_keys "$skill"
|
make_skill_with_source_keys "$skill"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ setup() {
|
|||||||
# SUGGESTION-freedom would be asserting the boundary check's absence instead
|
# SUGGESTION-freedom would be asserting the boundary check's absence instead
|
||||||
# of the thing it names. "anything else" is not hyphenated, so the clause adds
|
# of the thing it names. "anything else" is not hyphenated, so the clause adds
|
||||||
# a boundary marker without adding a routing target to resolve.
|
# a boundary marker without adding a routing target to resolve.
|
||||||
|
#
|
||||||
|
# metadata.version is equally load-bearing: ADR-0022 makes it mandatory and
|
||||||
|
# validate.sh FAILs without it, so a fixture omitting it would not be
|
||||||
|
# "otherwise clean" either.
|
||||||
make_valid_skill() {
|
make_valid_skill() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
local name
|
local name
|
||||||
@@ -25,6 +29,8 @@ setup() {
|
|||||||
---
|
---
|
||||||
name: $name
|
name: $name
|
||||||
description: A valid skill description that is well within the limit. Do not use for anything else.
|
description: A valid skill description that is well within the limit. Do not use for anything else.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1
|
## Step 1
|
||||||
@@ -59,6 +65,8 @@ PY
|
|||||||
echo "---"
|
echo "---"
|
||||||
echo "name: $name"
|
echo "name: $name"
|
||||||
echo "description: $desc"
|
echo "description: $desc"
|
||||||
|
echo "metadata:"
|
||||||
|
echo ' version: "1.0.0"'
|
||||||
echo "---"
|
echo "---"
|
||||||
echo ""
|
echo ""
|
||||||
python3 -c "print(' '.join(['word'] * $body_words))"
|
python3 -c "print(' '.join(['word'] * $body_words))"
|
||||||
@@ -294,6 +302,141 @@ EOF
|
|||||||
assert_success
|
assert_success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "prose inside a usage() here-doc that wraps onto a line starting with 'read' does not fail" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
# The exact shape that made skill-audit hard-FAIL on its own
|
||||||
|
# validate-provenance.sh: a usage() heredoc whose wrapped sentence puts the
|
||||||
|
# English verb "read" in column 0.
|
||||||
|
cat > "$skill/scripts/helper.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Checks performed:
|
||||||
|
4 A Contributing files block this parser cannot
|
||||||
|
read is reported as an INFO, never skipped silently.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
usage
|
||||||
|
SH
|
||||||
|
chmod +x "$skill/scripts/helper.sh"
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a real interactive read AFTER a here-doc is still caught" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
# Pins that the here-doc exemption ends at its terminator. A body skip that
|
||||||
|
# ran to end-of-file would swallow this read and report the script clean.
|
||||||
|
cat > "$skill/scripts/helper.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cat <<EOF
|
||||||
|
read this text
|
||||||
|
EOF
|
||||||
|
read -r ANSWER
|
||||||
|
SH
|
||||||
|
chmod +x "$skill/scripts/helper.sh"
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an unterminated here-doc opener does not disarm the check for the rest of the file" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
# `<<` here is inside a string, not an opener. Treating it as one would skip
|
||||||
|
# every following line — a false negative, the direction this check must
|
||||||
|
# never fail in.
|
||||||
|
cat > "$skill/scripts/helper.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "shift left with a << b"
|
||||||
|
read -r ANSWER
|
||||||
|
SH
|
||||||
|
chmod +x "$skill/scripts/helper.sh"
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a bare input() inside an embedded-python here-doc is still caught" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
# The here-doc exemption is for the `read` heuristic only: these scripts
|
||||||
|
# embed Python in a here-doc as a matter of course, so exempting the body
|
||||||
|
# wholesale would disarm the check across the corpus.
|
||||||
|
cat > "$skill/scripts/helper.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
print("press enter")
|
||||||
|
input()
|
||||||
|
PY
|
||||||
|
SH
|
||||||
|
chmod +x "$skill/scripts/helper.sh"
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ADR-0022 — metadata.version is mandatory. FAIL tier, matching the
|
||||||
|
# skill-frontmatter pre-commit hook: an audit that graded this lower would
|
||||||
|
# report ready-to-ship on a file the commit gate rejects.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@test "ADR-0022: a SKILL.md with no metadata block at all FAILs" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
python3 - "$skill/SKILL.md" <<'PY'
|
||||||
|
import sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
s = open(p).read().replace('metadata:\n version: "1.0.0"\n', '')
|
||||||
|
open(p, 'w').write(s)
|
||||||
|
PY
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
assert_output --partial "metadata.version"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "ADR-0022: a metadata block with no version key FAILs" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
python3 - "$skill/SKILL.md" <<'PY'
|
||||||
|
import sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
s = open(p).read().replace(' version: "1.0.0"\n', ' category: factory\n')
|
||||||
|
open(p, 'w').write(s)
|
||||||
|
PY
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
assert_output --partial "metadata.version"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "ADR-0022: a two-part metadata.version FAILs as malformed, not passes as present" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
python3 - "$skill/SKILL.md" <<'PY'
|
||||||
|
import sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
s = open(p).read().replace(' version: "1.0.0"\n', ' version: 1.0\n')
|
||||||
|
open(p, 'w').write(s)
|
||||||
|
PY
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_failure
|
||||||
|
assert_output --partial "three-part semver"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "ADR-0022: an unquoted three-part metadata.version passes" {
|
||||||
|
local skill="$TMPDIR/my-skill"
|
||||||
|
make_valid_skill "$skill"
|
||||||
|
python3 - "$skill/SKILL.md" <<'PY'
|
||||||
|
import sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
s = open(p).read().replace(' version: "1.0.0"\n', ' version: 0.1.3\n')
|
||||||
|
open(p, 'w').write(s)
|
||||||
|
PY
|
||||||
|
run bash "$SCRIPT" "$skill"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial "metadata.version present: '0.1.3'"
|
||||||
|
}
|
||||||
|
|
||||||
@test "fails when name contains consecutive hyphens" {
|
@test "fails when name contains consecutive hyphens" {
|
||||||
local skill="$TMPDIR/my--skill"
|
local skill="$TMPDIR/my--skill"
|
||||||
make_valid_skill "$skill"
|
make_valid_skill "$skill"
|
||||||
@@ -608,6 +751,8 @@ make_hand_invoked_skill() {
|
|||||||
echo "name: $name"
|
echo "name: $name"
|
||||||
echo "description: $desc"
|
echo "description: $desc"
|
||||||
echo "disable-model-invocation: true"
|
echo "disable-model-invocation: true"
|
||||||
|
echo "metadata:"
|
||||||
|
echo ' version: "1.0.0"'
|
||||||
echo "---"
|
echo "---"
|
||||||
echo ""
|
echo ""
|
||||||
python3 -c "print(' '.join(['word'] * $body_words))"
|
python3 -c "print(' '.join(['word'] * $body_words))"
|
||||||
@@ -761,6 +906,8 @@ make_hand_invoked_skill() {
|
|||||||
---
|
---
|
||||||
name: locale-skill
|
name: locale-skill
|
||||||
description: A valid skill description that is well within the limit.
|
description: A valid skill description that is well within the limit.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1
|
## Step 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Author and refine skills conforming to the [agentskills.io](https://agentskills.
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Routes to one of two flows based on context: if no skill directory exists at the target path, it scaffolds the directory from annotated templates, fills in `SKILL.md` and supporting files, and validates the result. If an existing skill directory and improvement signals are both present, it groups those signals by root cause and applies targeted edits, then re-validates. In both flows, bumps the skill's `metadata.version` when present (minor for create, patch for improve).
|
Routes to one of two flows based on context: if no skill directory exists at the target path, it scaffolds the directory from annotated templates, fills in `SKILL.md` and supporting files, and validates the result. If an existing skill directory and improvement signals are both present, it groups those signals by root cause and applies targeted edits, then re-validates. In both flows, bumps the skill's `metadata.version` — minor for create, patch for improve — which every skill carries (ADR-0022).
|
||||||
|
|
||||||
`SKILL.md` itself carries only the dispatch table, the invocation-axis decision, the contract gates and the shared close; each flow lives in its own self-contained reference file, per ADR-0020.
|
`SKILL.md` itself carries only the dispatch table, the invocation-axis decision, the contract gates and the shared close; each flow lives in its own self-contained reference file, per ADR-0020.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
Not read-only review -> `skill-audit`. Not agent files -> `agent-author`.
|
Not read-only review -> `skill-audit`. Not agent files -> `agent-author`.
|
||||||
allowed-tools: Bash Read Write Edit
|
allowed-tools: Bash Read Write Edit
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
@@ -34,7 +34,7 @@ metadata:
|
|||||||
|
|
||||||
Signals: grill output, `/skill-audit` findings, inline feedback, eval results, session context describing what went wrong. With none, ask whether the user meant to create a new skill or has feedback to apply.
|
Signals: grill output, `/skill-audit` findings, inline feedback, eval results, session context describing what went wrong. With none, ask whether the user meant to create a new skill or has feedback to apply.
|
||||||
|
|
||||||
Read only the reference matching the resolved flow — each is self-contained. If the target sits inside a git worktree, capture `git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
Read only the reference matching the resolved flow — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
||||||
|
|
||||||
## Step 2 — Invocation axis
|
## Step 2 — Invocation axis
|
||||||
|
|
||||||
@@ -59,4 +59,4 @@ Run `/skill-audit` on the resolved skill directory; resolve every FAIL before re
|
|||||||
|
|
||||||
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve.
|
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve.
|
||||||
|
|
||||||
**Commit verification.** Inside a git worktree: once the audit is clean, run `git add` and `git commit` — do not stop at staging. Re-run `git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.
|
**Commit verification.** Inside a git worktree: once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.
|
||||||
|
|||||||
@@ -45,14 +45,18 @@ description: >
|
|||||||
# Optional. 1–500 characters. State tool requirements, runtime versions,
|
# Optional. 1–500 characters. State tool requirements, runtime versions,
|
||||||
# and network access needs. Omit for skills with no special environment requirements.
|
# and network access needs. Omit for skills with no special environment requirements.
|
||||||
|
|
||||||
# metadata:
|
metadata:
|
||||||
|
version: "0.1.0"
|
||||||
# author: your-name
|
# author: your-name
|
||||||
# version: "1.0"
|
|
||||||
# category: general
|
# category: general
|
||||||
# source_keys:
|
# source_keys:
|
||||||
# - source-slug-one
|
# - source-slug-one
|
||||||
# - source-slug-two
|
# - source-slug-two
|
||||||
# Optional. Arbitrary key-value map. Common keys: author, version, category.
|
# `metadata.version` is REQUIRED on every skill (ADR-0022) and is enforced by the
|
||||||
|
# `skill-frontmatter` pre-commit hook. Three-component semver. A newly created
|
||||||
|
# skill starts at "0.1.0" — leave the seeded value as it is; "1.0.0" is the seed
|
||||||
|
# for a pre-existing skill retrofitted into the rule, not for a new one.
|
||||||
|
# The rest of the map is optional: author, category, source_keys.
|
||||||
# source_keys: populated when built from /research output. Lists slugs from references/sources.md.
|
# source_keys: populated when built from /research output. Lists slugs from references/sources.md.
|
||||||
# Also add source_keys to each references/*.md file that was informed by research.
|
# Also add source_keys to each references/*.md file that was informed by research.
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,10 @@ already covers the new skill. Use Read/Edit directly on `apm.yml`; this is not p
|
|||||||
## Step 3 — Fill in SKILL.md
|
## Step 3 — Fill in SKILL.md
|
||||||
|
|
||||||
Open the new skill's `SKILL.md` (the path Step 1 printed) and replace every `FILL IN:`
|
Open the new skill's `SKILL.md` (the path Step 1 printed) and replace every `FILL IN:`
|
||||||
placeholder. The scaffold template already carries the compliant frontmatter and body skeleton —
|
placeholder. The scaffold template carries the ADR-0020 body skeleton and the two frontmatter
|
||||||
fill it rather than restructuring it.
|
fields that cannot be left as placeholders — `name`, substituted by the script, and
|
||||||
|
`metadata.version`, seeded live at `"0.1.0"` — so fill the template in rather than restructuring
|
||||||
|
it.
|
||||||
|
|
||||||
**`name`** — already set by the scaffold script. Must exactly match the directory name. Format:
|
**`name`** — already set by the scaffold script. Must exactly match the directory name. Format:
|
||||||
1–64 characters, lowercase letters, numbers and hyphens only; no leading, trailing or consecutive
|
1–64 characters, lowercase letters, numbers and hyphens only; no leading, trailing or consecutive
|
||||||
@@ -96,15 +98,18 @@ against `references/contract.md`, which holds the three-part shape, the banned c
|
|||||||
boundary-clause form and the length tiers. A hand-invoked skill (`SKILL.md` Step 2) takes one
|
boundary-clause form and the length tiers. A hand-invoked skill (`SKILL.md` Step 2) takes one
|
||||||
plain sentence and `disable-model-invocation: true` instead.
|
plain sentence and `disable-model-invocation: true` instead.
|
||||||
|
|
||||||
|
**`metadata.version`** — required on every skill (ADR-0022), not a per-skill or per-plugin choice,
|
||||||
|
and enforced by the `skill-frontmatter` pre-commit hook. The scaffold seeds a new skill at
|
||||||
|
`"0.1.0"`; leave that value alone here and let `SKILL.md` Step 4 bump it. (`"1.0.0"` is the seed
|
||||||
|
for a pre-existing skill retrofitted into the rule, and never applies to a skill created here.)
|
||||||
|
|
||||||
**Optional frontmatter** — uncomment and fill in, or remove entirely:
|
**Optional frontmatter** — uncomment and fill in, or remove entirely:
|
||||||
|
|
||||||
- `license` — include when distributing the skill externally
|
- `license` — include when distributing the skill externally
|
||||||
- `compatibility` — include if the skill requires specific tools, runtimes, or network access
|
- `compatibility` — include if the skill requires specific tools, runtimes, or network access
|
||||||
(max 500 characters)
|
(max 500 characters)
|
||||||
- `metadata` — key-value map. `version` is **required** on every skill (ADR-0022), seeded at
|
- `metadata` — the rest of the map, all of it optional: `author` and `category`, plus `source_keys`
|
||||||
`"1.0.0"` for a retrofitted skill with no prior version and at `"0.1.0"` for a newly created
|
now (Step 6) if research sources are in context
|
||||||
skill; `author` and `category` stay optional; add `source_keys` now (Step 6) if research sources
|
|
||||||
are in context
|
|
||||||
- `allowed-tools` — space-separated pre-approved tools; reduces permission prompts (experimental —
|
- `allowed-tools` — space-separated pre-approved tools; reduces permission prompts (experimental —
|
||||||
support varies by client)
|
support varies by client)
|
||||||
- `disable-model-invocation` — hand-invoked skills only
|
- `disable-model-invocation` — hand-invoked skills only
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ improvise the cuts — four dry runs invented six to ten different answers to th
|
|||||||
If a signal points to a script or reference file, edit that file directly rather than adding a
|
If a signal points to a script or reference file, edit that file directly rather than adding a
|
||||||
workaround in SKILL.md.
|
workaround in SKILL.md.
|
||||||
|
|
||||||
|
**A skill carrying no `metadata.version` is seeded at `"1.0.0"`, not bumped** — `SKILL.md` Step 4's
|
||||||
|
patch bump presumes a version to bump, and ADR-0022 reserves `"0.1.0"` for a newly created skill.
|
||||||
|
`references/retrofit.md` carries the reasoning.
|
||||||
|
|
||||||
**Check for regressions before handing back.** `SKILL.md` Step 4 tells you to resolve every FAIL,
|
**Check for regressions before handing back.** `SKILL.md` Step 4 tells you to resolve every FAIL,
|
||||||
which says nothing about a check that passed *before* these edits and no longer does. Compare the
|
which says nothing about a check that passed *before* these edits and no longer does. Compare the
|
||||||
closing audit against the skill's pre-edit state — a PASS that has become a SUGGESTION, or a
|
closing audit against the skill's pre-edit state — a PASS that has become a SUGGESTION, or a
|
||||||
|
|||||||
@@ -105,16 +105,37 @@ them for you. After every retrofit that adds, removes or renames a file:
|
|||||||
zero.
|
zero.
|
||||||
- [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new
|
- [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new
|
||||||
file as missing `source_keys`.
|
file as missing `source_keys`.
|
||||||
- [ ] **Compression must not add authority the source text didn't have.** The bullet above is
|
|
||||||
about a `sources.md` entry going *stale* — Contributing files left uncited after content
|
## Compression must not add authority the source text didn't have
|
||||||
moves. This is a distinct failure: a compression or rewrite pass that upgrades an honest
|
|
||||||
hedge in a Description into an unsupported confident claim, without the underlying source
|
This one is **not** part of the checklist above, and deliberately so: it fires on a wording change
|
||||||
having changed at all — "no forge-specific content drawn directly from it beyond that"
|
with no file change at all, so a retrofit that adds and removes nothing still owes it.
|
||||||
quietly becoming "Grounds Step 2's dispatch table." Nothing in `/skill-audit`'s structural
|
|
||||||
checks catches this; a bash script can verify an entry is internally consistent, never
|
The `sources.md` bullet above is about an entry going *stale* — Contributing files left uncited
|
||||||
whether the claim is *true*. If a retrofit strengthens or otherwise changes the wording of a
|
after content moves. This is a distinct failure: a compression or rewrite pass that upgrades an
|
||||||
provenance claim, re-read the upstream research doc first and confirm the stronger wording
|
honest hedge in a Description into an unsupported confident claim, without the underlying source
|
||||||
is actually still true before committing it.
|
having changed at all — "no forge-specific content drawn directly from it beyond that" quietly
|
||||||
|
becoming "Grounds Step 2's dispatch table."
|
||||||
|
|
||||||
|
`/skill-audit`'s provenance script does now notice this class: it diffs each slug's `Description`
|
||||||
|
and `Contributing files` text against a base ref and raises an **INFO** when the wording changed.
|
||||||
|
That is a prompt, not a verdict — it reports only *that* the claim moved, never whether the new
|
||||||
|
claim is true, because a bash script can verify an entry is internally consistent and nothing more.
|
||||||
|
Answering it is this flow's job: if a retrofit strengthens or otherwise changes the wording of a
|
||||||
|
provenance claim, re-read the upstream research doc first and confirm the stronger wording is
|
||||||
|
actually still true before committing it.
|
||||||
|
|
||||||
|
## Versioning a retrofitted skill
|
||||||
|
|
||||||
|
`SKILL.md` Step 4 says to bump the **patch** version on improve, which presumes there is a version
|
||||||
|
to bump. A pre-ADR-0020 skill often carries none — `metadata.version` only became mandatory under
|
||||||
|
ADR-0022, and this flow is exactly where those skills surface.
|
||||||
|
|
||||||
|
A skill with no `metadata.version` is **seeded at `"1.0.0"`, not bumped**. `"0.1.0"` is reserved
|
||||||
|
for a skill created new by the create flow: it means "created and never yet revised", which
|
||||||
|
understates a skill that has been through retrofit and audit passes without tracking a version.
|
||||||
|
Add the field in this retrofit — the `skill-frontmatter` pre-commit hook blocks the commit without
|
||||||
|
it.
|
||||||
|
|
||||||
## Worked example — a description retrofit
|
## Worked example — a description retrofit
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ EOF
|
|||||||
assert_success
|
assert_success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "scaffold emits a live metadata.version seeded at 0.1.0 (ADR-0022)" {
|
||||||
|
# The scaffold must clear .pre-commit-config.yaml's `skill-frontmatter` hook
|
||||||
|
# on its first commit: a commented-out metadata block ships a skill with no
|
||||||
|
# version and is blocked. Assert the field is live, not a comment.
|
||||||
|
bash "$SCRIPT" my-tool "$DEST"
|
||||||
|
run grep -E '^metadata:$' "$DEST/my-tool/SKILL.md"
|
||||||
|
assert_success
|
||||||
|
run grep -E '^ version: "[0-9]+\.[0-9]+\.[0-9]+"$' "$DEST/my-tool/SKILL.md"
|
||||||
|
assert_success
|
||||||
|
assert_output --partial '0.1.0'
|
||||||
|
}
|
||||||
|
|
||||||
@test "walk-up skips a type-less apm.yml (marketplace-only) and finds a real package root further up" {
|
@test "walk-up skips a type-less apm.yml (marketplace-only) and finds a real package root further up" {
|
||||||
mkdir -p "$DEST/mid/sub"
|
mkdir -p "$DEST/mid/sub"
|
||||||
cat > "$DEST/apm.yml" <<'EOF'
|
cat > "$DEST/apm.yml" <<'EOF'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kyberforge",
|
"name": "kyberforge",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kyberforge",
|
"name": "kyberforge",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
"description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Defame1297",
|
"name": "Defame1297",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: kyberforge
|
name: kyberforge
|
||||||
version: 1.6.1
|
version: 1.6.2
|
||||||
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.
|
||||||
author:
|
author:
|
||||||
name: Defame1297
|
name: Defame1297
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
directory -> skill-audit.
|
directory -> skill-audit.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-websites-code-claude
|
- context7-websites-code-claude
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ Read the frontmatter before judging a single word.
|
|||||||
Its Claude Code counterpart has no equivalent field and stays model-invoked, so the two halves of
|
Its Claude Code counterpart has no equivalent field and stays model-invoked, so the two halves of
|
||||||
the pair carrying differently shaped descriptions is expected there rather than a
|
the pair carrying differently shaped descriptions is expected there rather than a
|
||||||
pair-consistency finding.
|
pair-consistency finding.
|
||||||
`user-invocable: false` does not belong in this bullet: it only blocks manual invocation and is
|
`user-invocable: false` does not belong in this bullet. The two are separate fields with opposite
|
||||||
independent of `disable-model-invocation` — an agent can be `user-invocable: false` and still
|
defaults — `disable-model-invocation` (default `false`) governs runtime auto-selection,
|
||||||
model-routed, in which case the three-part shape below still applies. It carries no
|
`user-invocable` (default `true`) governs manual invocation, and the retired `infer` field was
|
||||||
|
replaced by the pair rather than by either one. So `user-invocable: false` says nothing about
|
||||||
|
whether the agent is model-routed: judge that from `disable-model-invocation` alone, and where
|
||||||
|
that is absent the three-part shape below still applies. `user-invocable` carries no
|
||||||
description-quality contract of its own and is out of this file's scope entirely.
|
description-quality contract of its own and is out of this file's scope entirely.
|
||||||
- **No such flag** — the agent is model-invoked and the rest of this file applies.
|
- **No such flag** — the agent is model-invoked and the rest of this file applies.
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ those same sections, so any restatement is a copy that can disagree with the che
|
|||||||
If it carries one, it still has to be kebab-case.
|
If it carries one, it still has to be kebab-case.
|
||||||
- `Use proactively` is meaningful in a CC description and steers the runtime to offer the agent
|
- `Use proactively` is meaningful in a CC description and steers the runtime to offer the agent
|
||||||
unprompted. In a Copilot description it does nothing; `KyberforgeCopilot.ProactivePhrase` flags
|
unprompted. In a Copilot description it does nothing; `KyberforgeCopilot.ProactivePhrase` flags
|
||||||
it. The Copilot equivalent is `disable-model-invocation` / `user-invocable`, which changes the
|
it. The Copilot equivalent is `disable-model-invocation`, which changes the description contract
|
||||||
description contract entirely — see `references/description-quality.md`, Step 0.
|
entirely — see `references/description-quality.md`, Step 0.
|
||||||
|
|
||||||
## Pair consistency
|
## Pair consistency
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
Not read-only review -> `agent-audit`. Not skills -> `skill-author`.
|
Not read-only review -> `agent-audit`. Not skills -> `skill-author`.
|
||||||
allowed-tools: Bash Read Write Edit
|
allowed-tools: Bash Read Write Edit
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- context7-websites-code-claude
|
- context7-websites-code-claude
|
||||||
@@ -31,7 +31,7 @@ metadata:
|
|||||||
|
|
||||||
Signals: grill output, `agent-audit` findings, inline feedback, session context describing what went wrong. With none, ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?"
|
Signals: grill output, `agent-audit` findings, inline feedback, session context describing what went wrong. With none, ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?"
|
||||||
|
|
||||||
Read only the reference for the resolved flow. Capture `git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
Read only the reference for the resolved flow. Capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
||||||
|
|
||||||
## Step 2 — Scope
|
## Step 2 — Scope
|
||||||
|
|
||||||
@@ -62,4 +62,4 @@ Invoke `agent-audit` on each file written and resolve every FAIL before reportin
|
|||||||
|
|
||||||
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest.
|
At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest.
|
||||||
|
|
||||||
**Commit verification.** Once the audit is clean, run `git add` and `git commit` — do not stop at staging. Re-run `git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed.
|
**Commit verification.** Once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Audit a skill directory against the agentskills.io specification and the house c
|
|||||||
|
|
||||||
`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words).
|
`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words).
|
||||||
|
|
||||||
Alongside those it runs four shape checks that are not length measurements at all. Two are FAILs: every routing target named in the description — in the compressed `Not <thing> -> <name>` arrow **and** in the prose form — must resolve to a real skill or agent, and every `references/<file>.md` the body names must exist on disk. Three are SUGGESTIONs: a missing boundary clause, a Gotchas section over five entries, and a Gotchas section over 25% of the body. The resolution universe for boundary targets is derived by walking up from the audited `SKILL.md` — the authoring root above it, its own apm package, and that package's declared `apm.yml` dependencies — so a fresh clone and a machine that has run `apm install` return the same verdict. When no universe can be determined the check prints `INFO ... DID NOT RUN` and does not silently pass.
|
Alongside those it runs shape checks that are not length measurements at all. Three are FAILs: every routing target named in the description — in the compressed `Not <thing> -> <name>` arrow **and** in the prose form — must resolve to a real skill or agent; every `references/<file>.md` the body names must exist on disk; and `metadata.version` must be present and three-part semver (ADR-0022). That last one is FAIL rather than SUGGESTION because the `skill-frontmatter` pre-commit hook rejects the file without it — an audit grading it lower would report ready-to-ship on a file the commit gate refuses. Three are SUGGESTIONs: a missing boundary clause, a Gotchas section over five entries, and a Gotchas section over 25% of the body. The resolution universe for boundary targets is derived by walking up from the audited `SKILL.md` — the authoring root above it, its own apm package, and that package's declared `apm.yml` dependencies — so a fresh clone and a machine that has run `apm install` return the same verdict. When no universe can be determined the check prints `INFO ... DID NOT RUN` and does not silently pass.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ Provide the path to the skill directory to audit when invoking.
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `SKILL.md` | Skill instructions for agents |
|
| `SKILL.md` | Skill instructions for agents |
|
||||||
| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description presence and length, body-only word count, line and whole-file word ceilings, boundary-clause presence, boundary-target resolution, `references/` pointer existence, Gotchas entry count and body share, placeholder detection, script executable bit, and interactive-prompt detection |
|
| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description presence and length, `metadata.version` presence and semver shape (ADR-0022), body-only word count, line and whole-file word ceilings, boundary-clause presence, boundary-target resolution, `references/` pointer existence, Gotchas entry count and body share, placeholder detection, script executable bit, and interactive-prompt detection |
|
||||||
| `scripts/validate-provenance.sh` | Provenance validator — checks sources.md completeness, source_keys/slug consistency, Contributing files existence, bidirectional linkage, Research doc: fields, and upstream research doc alignment |
|
| `scripts/validate-provenance.sh` | Provenance validator — checks sources.md completeness, source_keys/slug consistency, Contributing files existence, bidirectional linkage, Research doc: fields, upstream research doc alignment, and (check 9, INFO only) whether a slug's `Description` or `Contributing files` text has changed since a base ref — `--base-ref=<ref>` or `VALIDATE_PROVENANCE_BASE_REF`, defaulting to the merge base with `origin/main` |
|
||||||
| `scripts/vale-wrap.sh` | Vale prefilter wrapper — runs the bundled `Kyberforge` Vale styles against SKILL.md and reports alerts as deterministic FAILs ahead of Step 3's qualitative review |
|
| `scripts/vale-wrap.sh` | Vale prefilter wrapper — runs the bundled `Kyberforge` Vale styles against SKILL.md and reports alerts as deterministic FAILs ahead of Step 3's qualitative review |
|
||||||
| `assets/vale/.vale.ini` | Vale configuration — points Vale at the bundled `Kyberforge` style path, self-located relative to `vale-wrap.sh` |
|
| `assets/vale/.vale.ini` | Vale configuration — points Vale at the bundled `Kyberforge` style path, self-located relative to `vale-wrap.sh` |
|
||||||
| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Vale rule — flags composition and architecture notes in a description (e.g. "cross-cutting", "entry point", "rather than duplicating") |
|
| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Vale rule — flags composition and architecture notes in a description (e.g. "cross-cutting", "entry point", "rather than duplicating") |
|
||||||
@@ -41,7 +41,7 @@ Provide the path to the skill directory to audit when invoking.
|
|||||||
| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed |
|
| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed |
|
||||||
| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift |
|
| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift |
|
||||||
| `references/formatting-and-scripts.md` | Rubric for the formatting and scripts dimensions — heading and fencing conventions, and the agentic-use criteria for bundled scripts |
|
| `references/formatting-and-scripts.md` | Rubric for the formatting and scripts dimensions — heading and fencing conventions, and the agentic-use criteria for bundled scripts |
|
||||||
| `references/validation-scripts.md` | Step 1 troubleshooting — the manual structural fallback when `validate.sh` cannot run, and the script exit codes that are easy to misread (loaded only on a script failure) |
|
| `references/validation-scripts.md` | Step 1 troubleshooting — the manual structural fallback when `validate.sh` cannot run, and the script exit codes that are easy to misread (loaded on a script failure, and on any exit-0 run that printed something — `validate-provenance.sh`'s check 9 is INFO-only, so its findings arrive that way) |
|
||||||
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
||||||
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
||||||
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
skill-author.
|
skill-author.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
@@ -36,9 +36,9 @@ bash scripts/vale-wrap.sh <skill-dir>/SKILL.md
|
|||||||
|
|
||||||
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation.
|
`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both, at the tier the script assigned. Report each once; never re-grade one under another dimension. Unresolved boundary targets are where this bites, because their tier turns on notation.
|
||||||
|
|
||||||
If any of the three cannot run, or exits non-zero for a reason other than findings, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes. Ordinary content FAILs are the expected outcome here and need no fallback.
|
Read `references/validation-scripts.md` when any of the three cannot run or exits non-zero for a reason other than findings, **and whenever `validate-provenance.sh` exits 0 having printed anything**. Ordinary content FAILs are the expected outcome here and need no fallback.
|
||||||
|
|
||||||
`validate-provenance.sh` prints nothing on success, so read its exit code before you read its silence. **0** is a genuine pass. **1** means real findings: its FAILs and INFOs become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. **2** means the check never ran — a usage or environment error, reason on stderr, no findings and often no stdout at all. On a 2, report `### Provenance` as unverified and quote the stderr reason. Never grade an exit 2 as a clean pass: empty stdout there means nothing was checked, not that nothing was wrong.
|
`validate-provenance.sh` reports through exit code **and** output; neither alone is the verdict. **0, silent** is a genuine pass. **0 with output** is INFO-only findings — still a `### Provenance` dimension; `references/validation-scripts.md` says what each obliges — for a check-9 INFO, reading rather than relaying. **1** is FAILs plus any INFOs; it emits Why and Fix itself — surface those verbatim. **2** means it never ran — a usage or environment error, reason on stderr, often no stdout — so report `### Provenance` unverified and quote that reason. Never grade an exit 2, or an exit 0 that printed, as a clean pass.
|
||||||
|
|
||||||
`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment:
|
`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment:
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ source_keys:
|
|||||||
|
|
||||||
# Validation Scripts Reference
|
# Validation Scripts Reference
|
||||||
|
|
||||||
Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting.
|
Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting —
|
||||||
Nothing here is needed on a clean run.
|
including `validate-provenance.sh` exiting **0 having printed something**, which is INFO findings,
|
||||||
|
not a clean run. Its silent exit 0 is the only outcome that needs nothing here.
|
||||||
|
|
||||||
## Report the gap, do not guess
|
## Report the gap, do not guess
|
||||||
|
|
||||||
@@ -106,18 +107,33 @@ Three ways to read the result wrong:
|
|||||||
`python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it
|
`python3` all exit **2** with a message on stderr. Exit 2 means the script never ran — report it
|
||||||
as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.
|
as an unaudited dimension, never as a pass and never as a finding. Exit 1 is findings.
|
||||||
- **A check-9 INFO — `'<field>' changed for '<slug>' since <ref>` — means go read, not just relay.**
|
- **A check-9 INFO — `'<field>' changed for '<slug>' since <ref>` — means go read, not just relay.**
|
||||||
Check 9 diffs the current `references/sources.md` against a base ref (default: the merge base with
|
Check 9 diffs the current `references/sources.md` against a base ref and flags a slug whose
|
||||||
`origin/main`) and flags a slug whose `Description` or `Contributing files` text differs. It is
|
`Description` or `Contributing files` text differs. It is structurally incapable of telling you
|
||||||
structurally incapable of telling you whether the new wording is still *true* — it only detects
|
whether the new wording is still *true* — it only detects that the text changed — so when this
|
||||||
that the text changed — so when this INFO fires, open the Contributing files it names and the
|
INFO fires, open that slug's own entry: the document named in its `Research doc:` field, and the
|
||||||
document named in that slug's `Research doc:` field, and confirm by reading whether the (possibly
|
files its `Contributing files` list names. Read whichever the changed field is a claim *about* —
|
||||||
|
a Description-only change often leaves the file list untouched, so "open the Contributing files"
|
||||||
|
is where to look, not proof that they are what moved. Confirm by reading whether the (possibly
|
||||||
strengthened) claim genuinely holds. This is the one provenance finding this script cannot verify
|
strengthened) claim genuinely holds. This is the one provenance finding this script cannot verify
|
||||||
for you: every other check here is a structural fact you can relay as-is, but check 9's job is
|
for you: every other check here is a structural fact you can relay as-is, but check 9's job is
|
||||||
only to tell you *where* to spend that reading effort, not to replace it. Acknowledging the INFO
|
only to tell you *where* to spend that reading effort, not to replace it. Acknowledging the INFO
|
||||||
without opening those files is not auditing it. A single INFO naming "no base ref could be
|
without opening those files is not auditing it. Its companion — `'<field>' removed for '<slug>'
|
||||||
resolved" or "no repo root above the skill directory" is the same graceful-skip pattern as every
|
since <ref>` — is the same obligation in the other direction: a claim withdrawn rather than
|
||||||
other check here that cannot run — treat it as an unaudited dimension for that reason, not as a
|
rewritten. No other check here requires the field, so confirm the removal was deliberate.
|
||||||
finding about the skill.
|
- **The check-9 base ref defaults to `git merge-base HEAD origin/main`, and there are two ways to
|
||||||
|
override it.** `--base-ref=<ref>` on the command line, or the `VALIDATE_PROVENANCE_BASE_REF`
|
||||||
|
environment variable; the flag wins when both are given, including when it is given empty
|
||||||
|
(`--base-ref=`), which selects the default resolution and ignores the environment. Reach for one
|
||||||
|
on a fork, a long-lived branch, or a mirror whose remote is not called `origin` — and when a
|
||||||
|
review asks what changed since a specific commit rather than since the branch point.
|
||||||
|
- **A single check-9 INFO naming a whole-check skip is an unaudited dimension, not a finding about
|
||||||
|
the skill.** There are three: "no repo root above the skill directory", "no base ref could be
|
||||||
|
resolved", and "`<path>` is not tracked at `<ref>`". The third is the one to read carefully — it
|
||||||
|
fires when the base ref resolved but `git show <ref>:<path>` did not, which covers both a
|
||||||
|
genuinely new `sources.md` (nothing to flag) and a path git does not know under that name: a
|
||||||
|
renamed skill directory, or an installed, gitignored copy such as a deployed `.claude/skills/`
|
||||||
|
tree. Auditing the deployed copy silently checks nothing; re-run against the authoring path under
|
||||||
|
`plugins/*/.apm/skills/`.
|
||||||
- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full
|
- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full
|
||||||
Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is
|
Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is
|
||||||
scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted.
|
scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ Arguments:
|
|||||||
a long-lived branch, a mirror with a different remote name).
|
a long-lived branch, a mirror with a different remote name).
|
||||||
The VALIDATE_PROVENANCE_BASE_REF environment variable is an
|
The VALIDATE_PROVENANCE_BASE_REF environment variable is an
|
||||||
equivalent, lower-precedence way to set it — the flag wins
|
equivalent, lower-precedence way to set it — the flag wins
|
||||||
if both are given.
|
if both are given, including when the flag is given empty
|
||||||
|
(\`--base-ref=\`), which selects the default resolution.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 All checks passed (or nothing to validate)
|
0 All checks passed (or nothing to validate)
|
||||||
@@ -47,10 +48,13 @@ Checks performed:
|
|||||||
8 Extracted non-(none) slug in research doc present in sources.md
|
8 Extracted non-(none) slug in research doc present in sources.md
|
||||||
9 Description or Contributing files text changed since --base-ref (INFO
|
9 Description or Contributing files text changed since --base-ref (INFO
|
||||||
only — a bash script cannot verify the claim is still TRUE, only that it
|
only — a bash script cannot verify the claim is still TRUE, only that it
|
||||||
changed; the auditor reads the named files to check that). A slug absent
|
changed; the auditor reads the named files to check that). Wrapped values
|
||||||
at the base ref is a creation, not a change, and is not flagged. When the
|
are joined before comparison, so a re-wrap alone is not a change and a
|
||||||
base ref cannot be resolved at all, this is announced as ONE INFO for the
|
rewrite of any line of one is. A slug absent at the base ref is a
|
||||||
whole check, never a silent skip.
|
creation, not a change, and is not flagged; a field that WAS there and is
|
||||||
|
now gone is announced as a removal. When the base ref cannot be resolved,
|
||||||
|
or references/sources.md is not tracked under this path at that ref, this
|
||||||
|
is announced as ONE INFO for the whole check, never a silent skip.
|
||||||
|
|
||||||
Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE
|
Checks 7 and 8 apply ONLY when the Research doc value names a research SOURCE
|
||||||
INDEX — a file whose basename is sources.md, whose H2 headings ARE source
|
INDEX — a file whose basename is sources.md, whose H2 headings ARE source
|
||||||
@@ -70,8 +74,15 @@ fi
|
|||||||
# never counts against them — a caller passing it alongside skill-dir sees
|
# never counts against them — a caller passing it alongside skill-dir sees
|
||||||
# the same argument-count behaviour as one who does not pass it at all, and a
|
# the same argument-count behaviour as one who does not pass it at all, and a
|
||||||
# genuinely extra positional argument is still rejected.
|
# genuinely extra positional argument is still rejected.
|
||||||
|
#
|
||||||
|
# BASE_REF_OVERRIDE is deliberately left UNSET here rather than initialised to
|
||||||
|
# the empty string. `--base-ref=` (given, but empty) and "no flag at all" are
|
||||||
|
# different instructions — the first says "use the default resolution, ignoring
|
||||||
|
# the environment", the second says "fall back to the environment" — and an
|
||||||
|
# empty-string initialiser collapsed them: `${BASE_REF_OVERRIDE:-$ENV}` treats
|
||||||
|
# an empty flag value as absent, so the environment variable won and the usage
|
||||||
|
# text's "the flag wins if both are given" was false for exactly that spelling.
|
||||||
declare -a POSITIONAL_ARGS=()
|
declare -a POSITIONAL_ARGS=()
|
||||||
BASE_REF_OVERRIDE=""
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--base-ref=*)
|
--base-ref=*)
|
||||||
@@ -107,10 +118,15 @@ fi
|
|||||||
|
|
||||||
SKILL_DIR_ARG="${POSITIONAL_ARGS[0]}"
|
SKILL_DIR_ARG="${POSITIONAL_ARGS[0]}"
|
||||||
|
|
||||||
# The flag wins over the environment variable when both are given; either is
|
# The flag wins over the environment variable whenever the flag was GIVEN —
|
||||||
# empty-string when unset, and an empty string tells the Python body to fall
|
# `+x` tests for presence, not for a non-empty value, which is the distinction
|
||||||
|
# `:-` could not make. An empty result either way tells the Python body to fall
|
||||||
# back to `git merge-base HEAD origin/main`.
|
# back to `git merge-base HEAD origin/main`.
|
||||||
BASE_REF="${BASE_REF_OVERRIDE:-${VALIDATE_PROVENANCE_BASE_REF:-}}"
|
if [[ -n "${BASE_REF_OVERRIDE+x}" ]]; then
|
||||||
|
BASE_REF="$BASE_REF_OVERRIDE"
|
||||||
|
else
|
||||||
|
BASE_REF="${VALIDATE_PROVENANCE_BASE_REF:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
# python3 is a HARD dependency. Without this preflight a missing interpreter
|
# python3 is a HARD dependency. Without this preflight a missing interpreter
|
||||||
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
|
# produced 'line NN: python3: command not found' and exit 127 — an exit code no
|
||||||
@@ -491,10 +507,21 @@ def find_repo_root(start_dir):
|
|||||||
# --- Check 9 helpers ---------------------------------------------------
|
# --- Check 9 helpers ---------------------------------------------------
|
||||||
# Check 9 needs a raw field VALUE (as text, to diff against an earlier
|
# Check 9 needs a raw field VALUE (as text, to diff against an earlier
|
||||||
# version), not the parsed structure parse_contributing_files() and
|
# version), not the parsed structure parse_contributing_files() and
|
||||||
# parse_status() return — a Contributing files list that reordered its
|
# parse_status() return. The ONE normalization applied is whitespace
|
||||||
# entries without changing them is not what this check is looking for, but
|
# collapsing, which is what makes a re-wrap or a re-indent invisible; nothing
|
||||||
# neither is normalizing so hard that a genuine rewrite disappears. Raw text,
|
# else is normalized away.
|
||||||
# whitespace-normalized, is the middle ground.
|
#
|
||||||
|
# In particular a REORDERED Contributing files list DOES fire this check, and
|
||||||
|
# that is deliberate — the header here used to claim the opposite, which the
|
||||||
|
# code never did. Order-insensitivity cannot be had for one field without
|
||||||
|
# distorting the other: the two fields share this parser, and the only way to
|
||||||
|
# ignore order is to split the value into items and sort them, which for a
|
||||||
|
# prose Description means splitting on commas and would then hide a genuine
|
||||||
|
# rewrite that merely permuted its clauses. Check 9 is always an INFO whose
|
||||||
|
# whole job is to point a human at a place to read; a reordered list costs
|
||||||
|
# that human one glance to dismiss, whereas a hidden rewrite is the exact
|
||||||
|
# failure #118 exists to catch. False positive over false negative, on this
|
||||||
|
# check, on purpose.
|
||||||
|
|
||||||
def run_git(args, cwd):
|
def run_git(args, cwd):
|
||||||
"""Run `git <args>` in cwd. Returns (returncode, stdout, stderr) — never
|
"""Run `git <args>` in cwd. Returns (returncode, stdout, stderr) — never
|
||||||
@@ -523,14 +550,44 @@ def find_slug_block(content, slug):
|
|||||||
m = pattern.search(content)
|
m = pattern.search(content)
|
||||||
return m.group(1) if m else None
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
# A field value ENDS at the next field, the next heading, or a blank line.
|
||||||
|
# Every other non-blank line is a continuation of the value the author wrapped
|
||||||
|
# across physical lines.
|
||||||
|
#
|
||||||
|
# This boundary is what the old `(.+)$` regex did not have. `.` does not cross
|
||||||
|
# a newline, so only the FIRST physical line of a wrapped value was ever
|
||||||
|
# compared — and a rewrite confined to a continuation line produced no finding
|
||||||
|
# at all. That is verbatim the hedge-to-confident-claim regression #118 exists
|
||||||
|
# to catch, invisible to the check written to catch it. The bullet branch had
|
||||||
|
# the same defect one level down: a wrapped bullet's continuation does not
|
||||||
|
# start with '- ', so the loop broke there and silently dropped every
|
||||||
|
# remaining bullet.
|
||||||
|
#
|
||||||
|
# A continuation line that itself opens with bold text ('**note** — ...') is
|
||||||
|
# read as a boundary and truncates the value. That is a known, narrow
|
||||||
|
# false-negative, accepted because the alternative — no boundary at all —
|
||||||
|
# is what produced the wide one above.
|
||||||
|
FIELD_BOUNDARY_RE = re.compile(r'^(?:- )?\*\*|^#{1,6} ')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_field_boundary(stripped_line):
|
||||||
|
"""True when a stripped line starts a new field, bullet-less heading or H2."""
|
||||||
|
return bool(FIELD_BOUNDARY_RE.match(stripped_line))
|
||||||
|
|
||||||
|
|
||||||
def parse_field_raw(content, slug, field_name):
|
def parse_field_raw(content, slug, field_name):
|
||||||
"""Raw text of a '**<field_name>:**' field under a slug H2.
|
"""Raw text of a '**<field_name>:**' field under a slug H2, wrapping joined.
|
||||||
|
|
||||||
Mirrors the two authored shapes parse_contributing_files() and
|
Mirrors the two authored shapes parse_contributing_files() and
|
||||||
parse_status() already handle (inline value on the same line, or a
|
parse_status() already handle (inline value on the same line, or a
|
||||||
bare heading followed by '- ' bullets), but returns text rather than a
|
bare heading followed by '- ' bullets), but returns text rather than a
|
||||||
parsed structure, because check 9 diffs wording, not semantics.
|
parsed structure, because check 9 diffs wording, not semantics.
|
||||||
|
|
||||||
|
Continuation lines are joined into the value they belong to before the
|
||||||
|
caller normalizes and compares, so a value wrapped across two lines and
|
||||||
|
the same value on one line are the same text — and a change made on any
|
||||||
|
line of a wrapped value is visible, not just one made on the first.
|
||||||
|
|
||||||
Returns None when the H2 itself is absent (the slug did not exist at
|
Returns None when the H2 itself is absent (the slug did not exist at
|
||||||
this content's revision) or the field is absent — both read as "no
|
this content's revision) or the field is absent — both read as "no
|
||||||
earlier claim to compare against" to the caller, which is deliberate:
|
earlier claim to compare against" to the caller, which is deliberate:
|
||||||
@@ -539,28 +596,49 @@ def parse_field_raw(content, slug, field_name):
|
|||||||
block = find_slug_block(content, slug)
|
block = find_slug_block(content, slug)
|
||||||
if block is None:
|
if block is None:
|
||||||
return None
|
return None
|
||||||
inline_re = re.compile(r'^\- \*\*' + re.escape(field_name) + r':\*\* (.+)$', re.MULTILINE)
|
lines = block.splitlines()
|
||||||
im = inline_re.search(block)
|
inline_re = re.compile(r'^\- \*\*' + re.escape(field_name) + r':\*\*[ \t]*(.*)$')
|
||||||
|
heading_re = re.compile(r'^\*\*' + re.escape(field_name) + r':\*\*[ \t]*$')
|
||||||
|
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
im = inline_re.match(line)
|
||||||
if im:
|
if im:
|
||||||
return im.group(1).strip()
|
parts = [im.group(1).strip()]
|
||||||
heading_re = re.compile(r'^\*\*' + re.escape(field_name) + r':\*\*\s*$', re.MULTILINE)
|
for cont in lines[idx + 1:]:
|
||||||
hm = heading_re.search(block)
|
stripped = cont.strip()
|
||||||
if not hm:
|
if not stripped or stripped.startswith("- ") or _is_field_boundary(stripped):
|
||||||
return None
|
break
|
||||||
lines = []
|
parts.append(stripped)
|
||||||
for line in block[hm.end():].splitlines():
|
joined = " ".join(p for p in parts if p).strip()
|
||||||
line = line.strip()
|
return joined or None
|
||||||
if not line:
|
if heading_re.match(line):
|
||||||
if lines:
|
entries = []
|
||||||
|
for cont in lines[idx + 1:]:
|
||||||
|
stripped = cont.strip()
|
||||||
|
if not stripped:
|
||||||
|
if entries:
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
if not line.startswith("- "):
|
if _is_field_boundary(stripped):
|
||||||
break
|
break
|
||||||
lines.append(line[2:].strip())
|
if stripped.startswith("- "):
|
||||||
return ", ".join(lines) if lines else None
|
entries.append(stripped[2:].strip())
|
||||||
|
elif entries:
|
||||||
|
# A wrapped bullet: fold it back into the bullet it
|
||||||
|
# continues rather than ending the list here.
|
||||||
|
entries[-1] = (entries[-1] + " " + stripped).strip()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return ", ".join(e for e in entries if e) or None
|
||||||
|
return None
|
||||||
|
|
||||||
def normalize_field_text(value):
|
def normalize_field_text(value):
|
||||||
"""Collapse whitespace so reformatting alone never registers as a change."""
|
"""Collapse whitespace so reformatting alone never registers as a change.
|
||||||
|
|
||||||
|
True only because parse_field_raw() joins wrapped continuation lines
|
||||||
|
first: collapsing whitespace inside a value that had already been
|
||||||
|
truncated at its first newline normalized nothing a re-wrap could change.
|
||||||
|
"""
|
||||||
return re.sub(r'\s+', ' ', value).strip()
|
return re.sub(r'\s+', ' ', value).strip()
|
||||||
|
|
||||||
findings = []
|
findings = []
|
||||||
@@ -1025,28 +1103,82 @@ else:
|
|||||||
["show", f"{resolved_base_ref}:{sources_md_relpath}"], repo_root
|
["show", f"{resolved_base_ref}:{sources_md_relpath}"], repo_root
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
# The base ref resolved fine, but references/sources.md did not
|
# The base ref resolved fine but `git show <ref>:<path>` did not.
|
||||||
# exist there at all — the whole file is new. Every entry in it
|
# That single return code covers two situations this check cannot
|
||||||
# is therefore a creation, not a change: nothing to flag, and
|
# tell apart, and only one of them is harmless:
|
||||||
# this is not a structural failure of the check, so no INFO
|
#
|
||||||
# either. Same reasoning applies per-slug below when the ref
|
# the file genuinely did not exist at the base ref — the whole
|
||||||
# resolved but a given '## <slug>' heading did not exist yet.
|
# sources.md is new, every entry in it is a creation, and there
|
||||||
|
# is nothing check 9 could have flagged;
|
||||||
|
#
|
||||||
|
# the path is not TRACKED under that name at the base ref — a
|
||||||
|
# renamed skill directory, or a copy of the skill living
|
||||||
|
# somewhere untracked or gitignored (an installed .claude/skills
|
||||||
|
# tree is the everyday case).
|
||||||
|
#
|
||||||
|
# Treating both as "creation, nothing to flag" made the second one
|
||||||
|
# a silent, whole-skill skip: the same directory audited at its
|
||||||
|
# authoring path reported changed claims and at its deployed path
|
||||||
|
# reported nothing, with no way to tell that from a clean run.
|
||||||
|
# That is the exact fail-open this script's own header forbids —
|
||||||
|
# "never a silent skip" — so announce it once for the whole check
|
||||||
|
# and hand over git's own stderr, which is the only diagnostic
|
||||||
|
# that separates the two cases.
|
||||||
|
detail = show_err.strip().splitlines()
|
||||||
|
detail = detail[0] if detail else "git gave no reason"
|
||||||
|
emit_info(
|
||||||
|
f"Check 9 skipped — '{sources_md_relpath}' is not tracked at {resolved_base_ref}",
|
||||||
|
"references/sources.md",
|
||||||
|
f"`git show {resolved_base_ref}:{sources_md_relpath}` failed ({detail}). "
|
||||||
|
f"Either the file did not exist at that ref — in which case every entry is a "
|
||||||
|
f"creation and there was nothing to flag — or this path is not tracked under "
|
||||||
|
f"that name there: a renamed skill directory, or an untracked or gitignored copy "
|
||||||
|
f"of the skill such as a deployed .claude/skills/ tree. "
|
||||||
|
f"Check 9 did not run for any slug in this skill. "
|
||||||
|
f"Re-run against the tracked authoring path, or pass --base-ref=<ref> naming a "
|
||||||
|
f"commit where this path exists."
|
||||||
|
)
|
||||||
old_sources_content = None
|
old_sources_content = None
|
||||||
|
|
||||||
if old_sources_content is not None:
|
if old_sources_content is not None:
|
||||||
for slug in unique_slugs:
|
for slug in unique_slugs:
|
||||||
changed_fields = []
|
changed_fields = []
|
||||||
|
removed_fields = []
|
||||||
for field_name in ("Description", "Contributing files"):
|
for field_name in ("Description", "Contributing files"):
|
||||||
old_value = parse_field_raw(old_sources_content, slug, field_name)
|
old_value = parse_field_raw(old_sources_content, slug, field_name)
|
||||||
new_value = parse_field_raw(sources_content, slug, field_name)
|
new_value = parse_field_raw(sources_content, slug, field_name)
|
||||||
if old_value is None or new_value is None:
|
if old_value is None and new_value is None:
|
||||||
|
continue
|
||||||
|
if old_value is None:
|
||||||
# No earlier claim to compare against — a brand-new
|
# No earlier claim to compare against — a brand-new
|
||||||
# entry, or a field that did not exist yet at the
|
# entry, or a field that did not exist yet at the
|
||||||
# base ref. That is a creation, not a change, and is
|
# base ref. That is a creation, not a change, and is
|
||||||
# never flagged.
|
# never flagged.
|
||||||
continue
|
continue
|
||||||
|
if new_value is None:
|
||||||
|
# The field existed at the base ref and is gone now.
|
||||||
|
# This was folded into the creation skip above, which
|
||||||
|
# justified only the other half: deleting a whole
|
||||||
|
# '- **Description:**' line left NO finding anywhere —
|
||||||
|
# no other check in this script requires the field, so
|
||||||
|
# a claim could be removed as invisibly as it could be
|
||||||
|
# strengthened. Announce it; the auditor decides
|
||||||
|
# whether the removal was intended.
|
||||||
|
removed_fields.append(field_name)
|
||||||
|
continue
|
||||||
if normalize_field_text(old_value) != normalize_field_text(new_value):
|
if normalize_field_text(old_value) != normalize_field_text(new_value):
|
||||||
changed_fields.append(field_name)
|
changed_fields.append(field_name)
|
||||||
|
if removed_fields:
|
||||||
|
removed_list = " and ".join(removed_fields)
|
||||||
|
emit_info(
|
||||||
|
f"'{removed_list}' removed for '{slug}' since {resolved_base_ref}",
|
||||||
|
f"references/sources.md (## {slug})",
|
||||||
|
f"The '## {slug}' entry had {removed_list} at {resolved_base_ref} and has "
|
||||||
|
f"none now. Nothing else in this script requires the field, so the removal "
|
||||||
|
f"is otherwise invisible. Confirm it was deliberate — a provenance claim "
|
||||||
|
f"withdrawn is as much a change to the chain as one rewritten — and "
|
||||||
|
f"restore the field if it was lost to an edit."
|
||||||
|
)
|
||||||
if changed_fields:
|
if changed_fields:
|
||||||
field_list = " and ".join(changed_fields)
|
field_list = " and ".join(changed_fields)
|
||||||
emit_info(
|
emit_info(
|
||||||
|
|||||||
@@ -1289,6 +1289,50 @@ else:
|
|||||||
if desc:
|
if desc:
|
||||||
ok("description has no unfilled placeholders")
|
ok("description has no unfilled placeholders")
|
||||||
|
|
||||||
|
# --- ADR-0022: metadata.version is mandatory -------------------------------
|
||||||
|
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
|
||||||
|
# `.pre-commit-config.yaml`'s `skill-frontmatter` hook REJECTS a SKILL.md with
|
||||||
|
# no `metadata.version`, and rejects a value that is not three-part semver.
|
||||||
|
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
|
||||||
|
# tier below FAIL lets that step report done on a skill the commit gate then
|
||||||
|
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
|
||||||
|
# below warns about, arrived at from the other direction. Verified before this
|
||||||
|
# check existed: a SKILL.md with no `metadata:` block at all reported "All
|
||||||
|
# checks passed".
|
||||||
|
#
|
||||||
|
# The rule is DUPLICATED from that hook for the same cache-isolation reason as
|
||||||
|
# every other constant here — an installed plugin's scripts cannot read the
|
||||||
|
# repo-root config. Keep the two in step: this check must accept exactly what
|
||||||
|
# the hook accepts.
|
||||||
|
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
|
||||||
|
|
||||||
|
try:
|
||||||
|
fm_data = yaml.safe_load(fm)
|
||||||
|
except Exception:
|
||||||
|
# Unreachable in practice: description_value() above parses the same text
|
||||||
|
# and hard-exits on a YAML error, so anything arriving here already parsed.
|
||||||
|
fm_data = None
|
||||||
|
metadata_block = fm_data.get('metadata') if isinstance(fm_data, dict) else None
|
||||||
|
|
||||||
|
if not isinstance(metadata_block, dict) or metadata_block.get('version') is None:
|
||||||
|
fail("frontmatter has no metadata.version — ADR-0022 makes it mandatory for "
|
||||||
|
"every skill, and the skill-frontmatter pre-commit hook rejects the file "
|
||||||
|
"without it. Add `metadata:` / ` version: \"1.0.0\"` (new skills start "
|
||||||
|
"at \"0.1.0\")")
|
||||||
|
else:
|
||||||
|
version_value = metadata_block['version']
|
||||||
|
# NOT str()-coerced blind: `version: 1.0` is a YAML float, and its "1.0"
|
||||||
|
# spelling is exactly the two-part value the hook rejects — coercing and
|
||||||
|
# then matching keeps this check and the hook agreeing on that case.
|
||||||
|
version_text = version_value if isinstance(version_value, str) else str(version_value)
|
||||||
|
version_text = version_text.strip()
|
||||||
|
if SEMVER_RE.match(version_text):
|
||||||
|
ok(f"metadata.version present: '{version_text}' (ADR-0022)")
|
||||||
|
else:
|
||||||
|
fail(f"metadata.version '{version_text}' is not three-part semver — the "
|
||||||
|
f"skill-frontmatter pre-commit hook rejects it. Use MAJOR.MINOR.PATCH, "
|
||||||
|
f"e.g. \"1.0.0\"")
|
||||||
|
|
||||||
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
||||||
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
||||||
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
||||||
@@ -1515,11 +1559,66 @@ def stdin_redirected(line, prev_line):
|
|||||||
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
|
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
|
||||||
return '<' in unquoted or prev_line.rstrip().endswith('|')
|
return '<' in unquoted or prev_line.rstrip().endswith('|')
|
||||||
|
|
||||||
|
# A here-doc body is DATA, not command position. Every script in this corpus
|
||||||
|
# carries a `usage() { cat <<EOF ... EOF; }`, and prose wrapped inside one puts
|
||||||
|
# ordinary English at the start of a line — "read is reported as an INFO ..."
|
||||||
|
# in this skill's own validate-provenance.sh, which made skill-audit hard-FAIL
|
||||||
|
# on its own script. Reflowing that one sentence would have cleared the finding
|
||||||
|
# and left the cause: every future usage text is one wrap away from the same
|
||||||
|
# false positive, and the remedy an author reaches for is contorting working
|
||||||
|
# source, which the note above records has already happened twice.
|
||||||
|
#
|
||||||
|
# Detection is deliberately conservative in the direction that matters. A
|
||||||
|
# here-doc body is skipped only when its terminator is actually found further
|
||||||
|
# down the file; an opener with no terminator — the shape a stray `<<` inside a
|
||||||
|
# string would produce — is ignored rather than allowed to swallow the tail,
|
||||||
|
# because swallowing the tail is a false NEGATIVE and this check exists to fail
|
||||||
|
# closed. `<<<` here-strings open nothing and are excluded by the lookbehind.
|
||||||
|
HEREDOC_START_RE = re.compile(r'(?<!<)<<-?\s*(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1')
|
||||||
|
|
||||||
|
|
||||||
|
def heredoc_delimiter(line):
|
||||||
|
"""The here-doc terminator this line opens, or None."""
|
||||||
|
m = HEREDOC_START_RE.search(line)
|
||||||
|
return m.group(2) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def heredoc_body_indices(lines):
|
||||||
|
"""Line indices that are here-doc BODY (plus its terminator), not code."""
|
||||||
|
skip = set()
|
||||||
|
i, n = 0, len(lines)
|
||||||
|
while i < n:
|
||||||
|
stripped = lines[i].strip()
|
||||||
|
delim = None if stripped.startswith('#') else heredoc_delimiter(lines[i])
|
||||||
|
if delim:
|
||||||
|
# `<<-` allows an indented terminator, so compare stripped.
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
if lines[j].strip() == delim:
|
||||||
|
skip.update(range(i + 1, j + 1))
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
return skip
|
||||||
|
|
||||||
|
|
||||||
|
# The here-doc exemption applies to the `read` heuristic ONLY, and the
|
||||||
|
# asymmetry is the point. `read` is an ordinary English verb, so any prose a
|
||||||
|
# script prints is one line-wrap away from opening with it. `input(` is not a
|
||||||
|
# word — a line beginning `input(` inside a here-doc is an embedded Python
|
||||||
|
# program pausing for a keypress, which is exactly what this check is for, and
|
||||||
|
# these scripts embed Python in a here-doc as a matter of course. Exempting the
|
||||||
|
# whole body would have disarmed the check across every script in the corpus.
|
||||||
def interactive_reads(source):
|
def interactive_reads(source):
|
||||||
hits = []
|
hits = []
|
||||||
prev_line = ''
|
prev_line = ''
|
||||||
for line in source.splitlines():
|
lines = source.splitlines()
|
||||||
|
in_heredoc = heredoc_body_indices(lines)
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
if idx in in_heredoc:
|
||||||
|
if re.match(r'input\(', stripped):
|
||||||
|
hits.append(stripped)
|
||||||
|
continue
|
||||||
if re.match(r'read(\s|$)', stripped):
|
if re.match(r'read(\s|$)', stripped):
|
||||||
if not stdin_redirected(line, prev_line):
|
if not stdin_redirected(line, prev_line):
|
||||||
hits.append(stripped)
|
hits.append(stripped)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Author and refine skills conforming to the [agentskills.io](https://agentskills.
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Routes to one of two flows based on context: if no skill directory exists at the target path, it scaffolds the directory from annotated templates, fills in `SKILL.md` and supporting files, and validates the result. If an existing skill directory and improvement signals are both present, it groups those signals by root cause and applies targeted edits, then re-validates. In both flows, bumps the skill's `metadata.version` when present (minor for create, patch for improve).
|
Routes to one of two flows based on context: if no skill directory exists at the target path, it scaffolds the directory from annotated templates, fills in `SKILL.md` and supporting files, and validates the result. If an existing skill directory and improvement signals are both present, it groups those signals by root cause and applies targeted edits, then re-validates. In both flows, bumps the skill's `metadata.version` — minor for create, patch for improve — which every skill carries (ADR-0022).
|
||||||
|
|
||||||
`SKILL.md` itself carries only the dispatch table, the invocation-axis decision, the contract gates and the shared close; each flow lives in its own self-contained reference file, per ADR-0020.
|
`SKILL.md` itself carries only the dispatch table, the invocation-axis decision, the contract gates and the shared close; each flow lives in its own self-contained reference file, per ADR-0020.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
Not read-only review -> `skill-audit`. Not agent files -> `agent-author`.
|
Not read-only review -> `skill-audit`. Not agent files -> `agent-author`.
|
||||||
allowed-tools: Bash Read Write Edit
|
allowed-tools: Bash Read Write Edit
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
@@ -34,7 +34,7 @@ metadata:
|
|||||||
|
|
||||||
Signals: grill output, `/skill-audit` findings, inline feedback, eval results, session context describing what went wrong. With none, ask whether the user meant to create a new skill or has feedback to apply.
|
Signals: grill output, `/skill-audit` findings, inline feedback, eval results, session context describing what went wrong. With none, ask whether the user meant to create a new skill or has feedback to apply.
|
||||||
|
|
||||||
Read only the reference matching the resolved flow — each is self-contained. If the target sits inside a git worktree, capture `git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
Read only the reference matching the resolved flow — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
|
||||||
|
|
||||||
## Step 2 — Invocation axis
|
## Step 2 — Invocation axis
|
||||||
|
|
||||||
@@ -59,4 +59,4 @@ Run `/skill-audit` on the resolved skill directory; resolve every FAIL before re
|
|||||||
|
|
||||||
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve.
|
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve.
|
||||||
|
|
||||||
**Commit verification.** Inside a git worktree: once the audit is clean, run `git add` and `git commit` — do not stop at staging. Re-run `git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.
|
**Commit verification.** Inside a git worktree: once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.
|
||||||
|
|||||||
@@ -45,14 +45,18 @@ description: >
|
|||||||
# Optional. 1–500 characters. State tool requirements, runtime versions,
|
# Optional. 1–500 characters. State tool requirements, runtime versions,
|
||||||
# and network access needs. Omit for skills with no special environment requirements.
|
# and network access needs. Omit for skills with no special environment requirements.
|
||||||
|
|
||||||
# metadata:
|
metadata:
|
||||||
|
version: "0.1.0"
|
||||||
# author: your-name
|
# author: your-name
|
||||||
# version: "1.0"
|
|
||||||
# category: general
|
# category: general
|
||||||
# source_keys:
|
# source_keys:
|
||||||
# - source-slug-one
|
# - source-slug-one
|
||||||
# - source-slug-two
|
# - source-slug-two
|
||||||
# Optional. Arbitrary key-value map. Common keys: author, version, category.
|
# `metadata.version` is REQUIRED on every skill (ADR-0022) and is enforced by the
|
||||||
|
# `skill-frontmatter` pre-commit hook. Three-component semver. A newly created
|
||||||
|
# skill starts at "0.1.0" — leave the seeded value as it is; "1.0.0" is the seed
|
||||||
|
# for a pre-existing skill retrofitted into the rule, not for a new one.
|
||||||
|
# The rest of the map is optional: author, category, source_keys.
|
||||||
# source_keys: populated when built from /research output. Lists slugs from references/sources.md.
|
# source_keys: populated when built from /research output. Lists slugs from references/sources.md.
|
||||||
# Also add source_keys to each references/*.md file that was informed by research.
|
# Also add source_keys to each references/*.md file that was informed by research.
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,10 @@ already covers the new skill. Use Read/Edit directly on `apm.yml`; this is not p
|
|||||||
## Step 3 — Fill in SKILL.md
|
## Step 3 — Fill in SKILL.md
|
||||||
|
|
||||||
Open the new skill's `SKILL.md` (the path Step 1 printed) and replace every `FILL IN:`
|
Open the new skill's `SKILL.md` (the path Step 1 printed) and replace every `FILL IN:`
|
||||||
placeholder. The scaffold template already carries the compliant frontmatter and body skeleton —
|
placeholder. The scaffold template carries the ADR-0020 body skeleton and the two frontmatter
|
||||||
fill it rather than restructuring it.
|
fields that cannot be left as placeholders — `name`, substituted by the script, and
|
||||||
|
`metadata.version`, seeded live at `"0.1.0"` — so fill the template in rather than restructuring
|
||||||
|
it.
|
||||||
|
|
||||||
**`name`** — already set by the scaffold script. Must exactly match the directory name. Format:
|
**`name`** — already set by the scaffold script. Must exactly match the directory name. Format:
|
||||||
1–64 characters, lowercase letters, numbers and hyphens only; no leading, trailing or consecutive
|
1–64 characters, lowercase letters, numbers and hyphens only; no leading, trailing or consecutive
|
||||||
@@ -96,15 +98,18 @@ against `references/contract.md`, which holds the three-part shape, the banned c
|
|||||||
boundary-clause form and the length tiers. A hand-invoked skill (`SKILL.md` Step 2) takes one
|
boundary-clause form and the length tiers. A hand-invoked skill (`SKILL.md` Step 2) takes one
|
||||||
plain sentence and `disable-model-invocation: true` instead.
|
plain sentence and `disable-model-invocation: true` instead.
|
||||||
|
|
||||||
|
**`metadata.version`** — required on every skill (ADR-0022), not a per-skill or per-plugin choice,
|
||||||
|
and enforced by the `skill-frontmatter` pre-commit hook. The scaffold seeds a new skill at
|
||||||
|
`"0.1.0"`; leave that value alone here and let `SKILL.md` Step 4 bump it. (`"1.0.0"` is the seed
|
||||||
|
for a pre-existing skill retrofitted into the rule, and never applies to a skill created here.)
|
||||||
|
|
||||||
**Optional frontmatter** — uncomment and fill in, or remove entirely:
|
**Optional frontmatter** — uncomment and fill in, or remove entirely:
|
||||||
|
|
||||||
- `license` — include when distributing the skill externally
|
- `license` — include when distributing the skill externally
|
||||||
- `compatibility` — include if the skill requires specific tools, runtimes, or network access
|
- `compatibility` — include if the skill requires specific tools, runtimes, or network access
|
||||||
(max 500 characters)
|
(max 500 characters)
|
||||||
- `metadata` — key-value map. `version` is **required** on every skill (ADR-0022), seeded at
|
- `metadata` — the rest of the map, all of it optional: `author` and `category`, plus `source_keys`
|
||||||
`"1.0.0"` for a retrofitted skill with no prior version and at `"0.1.0"` for a newly created
|
now (Step 6) if research sources are in context
|
||||||
skill; `author` and `category` stay optional; add `source_keys` now (Step 6) if research sources
|
|
||||||
are in context
|
|
||||||
- `allowed-tools` — space-separated pre-approved tools; reduces permission prompts (experimental —
|
- `allowed-tools` — space-separated pre-approved tools; reduces permission prompts (experimental —
|
||||||
support varies by client)
|
support varies by client)
|
||||||
- `disable-model-invocation` — hand-invoked skills only
|
- `disable-model-invocation` — hand-invoked skills only
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ improvise the cuts — four dry runs invented six to ten different answers to th
|
|||||||
If a signal points to a script or reference file, edit that file directly rather than adding a
|
If a signal points to a script or reference file, edit that file directly rather than adding a
|
||||||
workaround in SKILL.md.
|
workaround in SKILL.md.
|
||||||
|
|
||||||
|
**A skill carrying no `metadata.version` is seeded at `"1.0.0"`, not bumped** — `SKILL.md` Step 4's
|
||||||
|
patch bump presumes a version to bump, and ADR-0022 reserves `"0.1.0"` for a newly created skill.
|
||||||
|
`references/retrofit.md` carries the reasoning.
|
||||||
|
|
||||||
**Check for regressions before handing back.** `SKILL.md` Step 4 tells you to resolve every FAIL,
|
**Check for regressions before handing back.** `SKILL.md` Step 4 tells you to resolve every FAIL,
|
||||||
which says nothing about a check that passed *before* these edits and no longer does. Compare the
|
which says nothing about a check that passed *before* these edits and no longer does. Compare the
|
||||||
closing audit against the skill's pre-edit state — a PASS that has become a SUGGESTION, or a
|
closing audit against the skill's pre-edit state — a PASS that has become a SUGGESTION, or a
|
||||||
|
|||||||
@@ -105,16 +105,37 @@ them for you. After every retrofit that adds, removes or renames a file:
|
|||||||
zero.
|
zero.
|
||||||
- [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new
|
- [ ] Re-run `/skill-audit` and confirm its `### Provenance` dimension does not report the new
|
||||||
file as missing `source_keys`.
|
file as missing `source_keys`.
|
||||||
- [ ] **Compression must not add authority the source text didn't have.** The bullet above is
|
|
||||||
about a `sources.md` entry going *stale* — Contributing files left uncited after content
|
## Compression must not add authority the source text didn't have
|
||||||
moves. This is a distinct failure: a compression or rewrite pass that upgrades an honest
|
|
||||||
hedge in a Description into an unsupported confident claim, without the underlying source
|
This one is **not** part of the checklist above, and deliberately so: it fires on a wording change
|
||||||
having changed at all — "no forge-specific content drawn directly from it beyond that"
|
with no file change at all, so a retrofit that adds and removes nothing still owes it.
|
||||||
quietly becoming "Grounds Step 2's dispatch table." Nothing in `/skill-audit`'s structural
|
|
||||||
checks catches this; a bash script can verify an entry is internally consistent, never
|
The `sources.md` bullet above is about an entry going *stale* — Contributing files left uncited
|
||||||
whether the claim is *true*. If a retrofit strengthens or otherwise changes the wording of a
|
after content moves. This is a distinct failure: a compression or rewrite pass that upgrades an
|
||||||
provenance claim, re-read the upstream research doc first and confirm the stronger wording
|
honest hedge in a Description into an unsupported confident claim, without the underlying source
|
||||||
is actually still true before committing it.
|
having changed at all — "no forge-specific content drawn directly from it beyond that" quietly
|
||||||
|
becoming "Grounds Step 2's dispatch table."
|
||||||
|
|
||||||
|
`/skill-audit`'s provenance script does now notice this class: it diffs each slug's `Description`
|
||||||
|
and `Contributing files` text against a base ref and raises an **INFO** when the wording changed.
|
||||||
|
That is a prompt, not a verdict — it reports only *that* the claim moved, never whether the new
|
||||||
|
claim is true, because a bash script can verify an entry is internally consistent and nothing more.
|
||||||
|
Answering it is this flow's job: if a retrofit strengthens or otherwise changes the wording of a
|
||||||
|
provenance claim, re-read the upstream research doc first and confirm the stronger wording is
|
||||||
|
actually still true before committing it.
|
||||||
|
|
||||||
|
## Versioning a retrofitted skill
|
||||||
|
|
||||||
|
`SKILL.md` Step 4 says to bump the **patch** version on improve, which presumes there is a version
|
||||||
|
to bump. A pre-ADR-0020 skill often carries none — `metadata.version` only became mandatory under
|
||||||
|
ADR-0022, and this flow is exactly where those skills surface.
|
||||||
|
|
||||||
|
A skill with no `metadata.version` is **seeded at `"1.0.0"`, not bumped**. `"0.1.0"` is reserved
|
||||||
|
for a skill created new by the create flow: it means "created and never yet revised", which
|
||||||
|
understates a skill that has been through retrofit and audit passes without tracking a version.
|
||||||
|
Add the field in this retrofit — the `skill-frontmatter` pre-commit hook blocks the commit without
|
||||||
|
it.
|
||||||
|
|
||||||
## Worked example — a description retrofit
|
## Worked example — a description retrofit
|
||||||
|
|
||||||
|
|||||||
198
scripts/check-rtk-prefix.sh
Executable file
198
scripts/check-rtk-prefix.sh
Executable file
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ADR-0023 clause 1, and ONLY clause 1: an executable, instructed local git
|
||||||
|
# command in plugin skill or agent content is written `rtk git`, never bare
|
||||||
|
# `git`.
|
||||||
|
#
|
||||||
|
# WHY THIS IS NARROW ON PURPOSE. ADR-0023 has three clauses, and only the first
|
||||||
|
# is machine-decidable:
|
||||||
|
#
|
||||||
|
# 1. executable + instructed -> `rtk git` <- this hook
|
||||||
|
# 2. illustrative/referential -> bare `git` <- undecidable, not gated
|
||||||
|
# 3. machine-parsed or interactive -> bare `git`, marked <- opt-out, below
|
||||||
|
#
|
||||||
|
# Clause 2 is a judgement about what a sentence is doing, not a pattern. "`git
|
||||||
|
# switch` refuses rather than clobbering conflicting local edits" and "run `git
|
||||||
|
# switch <branch>`" are the same token sequence in prose. A gate that guessed
|
||||||
|
# would fire on every doc paragraph in the corpus, and a gate that fires on
|
||||||
|
# correct content gets disabled. So this hook looks only at the two places where
|
||||||
|
# a `git` mention is unambiguously an instruction to execute:
|
||||||
|
#
|
||||||
|
# (a) a line inside a fenced code block whose info string names a shell
|
||||||
|
# (bash / sh / shell / zsh / console);
|
||||||
|
# (b) the OPENING backticked span of a "Run" column cell in a markdown
|
||||||
|
# dispatch table -- and only the opening span.
|
||||||
|
#
|
||||||
|
# (b) is that narrow because a Run cell routinely carries a command followed by
|
||||||
|
# prose about it, and that prose is clause 2. git-worktrees/SKILL.md has both
|
||||||
|
# shapes on adjacent rows: a cell reading `rtk git worktree add --track ...` --
|
||||||
|
# always correct. `git worktree add <path> <branch>` expands to exactly this
|
||||||
|
# (instruction first, reference second), and a `**Never** ...` row whose Run cell
|
||||||
|
# is entirely explanatory prose containing a bare `git push`. Checking every span
|
||||||
|
# flags both; checking only a leading span flags neither, and still catches the
|
||||||
|
# ordinary `| List | `git worktree list -v` |` case this gate exists for.
|
||||||
|
#
|
||||||
|
# Prose bullets, prose-leading Run cells, table cells outside a Run column, and
|
||||||
|
# fences tagged `text`, `yaml`, `json` etc. are NOT checked. That is a real
|
||||||
|
# coverage gap, recorded in docs/spec/gates.md rather than papered over.
|
||||||
|
#
|
||||||
|
# CLAUSE-3 OPT-OUT. A site that is deliberately bare because rtk rewrites the
|
||||||
|
# output the skill parses, or because the command is interactive, is exempted by
|
||||||
|
# putting the literal string `ADR-0023` on the SAME LINE — in a shell comment for
|
||||||
|
# a code line, in the cell text for a table row. Per-line, never per-block: a
|
||||||
|
# fenced block routinely mixes `rtk git` steps with one deliberately-bare
|
||||||
|
# command (references/push.md does exactly that), and a block-level marker would
|
||||||
|
# silently disarm the checked lines around the marked one.
|
||||||
|
#
|
||||||
|
# The marker is a bare substring match, so a line that merely *mentions*
|
||||||
|
# ADR-0023 for an unrelated reason is also exempt. That is accepted: the marker
|
||||||
|
# is an author's deliberate opt-out, not a security boundary, and a stricter
|
||||||
|
# form would only move the same trust to a different string.
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v python3 > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: python3 is required for the ADR-0023 rtk-prefix gate but was not found on PATH." >&2
|
||||||
|
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No PyYAML here, unlike skill-size-check.sh: this gate never reads frontmatter,
|
||||||
|
# only the markdown body, so it has no folded scalar to measure.
|
||||||
|
exec python3 -u - "$@" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
MARKER = "ADR-0023"
|
||||||
|
SHELL_INFO = {"bash", "sh", "shell", "zsh", "console", "shell-session"}
|
||||||
|
|
||||||
|
FENCE_OPEN = re.compile(r"^\s*(?P<f>`{3,}|~{3,})\s*(?P<info>[^\s`]*)")
|
||||||
|
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+")
|
||||||
|
# Shell separators that begin a fresh command word.
|
||||||
|
SPLIT = re.compile(r"(?:\|\||&&|[;|&\n]|\$\(|`|\()")
|
||||||
|
BACKTICKED = re.compile(r"`([^`]+)`")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_shell_comment(line: str) -> str:
|
||||||
|
"""Drop a trailing `#` comment, ignoring `#` inside single/double quotes."""
|
||||||
|
out = []
|
||||||
|
quote = None
|
||||||
|
prev = ""
|
||||||
|
for ch in line:
|
||||||
|
if quote:
|
||||||
|
if ch == quote and prev != "\\":
|
||||||
|
quote = None
|
||||||
|
elif ch in "'\"":
|
||||||
|
quote = ch
|
||||||
|
elif ch == "#" and (not out or out[-1].isspace()):
|
||||||
|
break
|
||||||
|
out.append(ch)
|
||||||
|
prev = ch
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def bare_git_in_shell(code: str) -> bool:
|
||||||
|
for segment in SPLIT.split(code):
|
||||||
|
seg = segment.lstrip()
|
||||||
|
if seg.startswith("$ "): # a copied prompt
|
||||||
|
seg = seg[2:].lstrip()
|
||||||
|
while True: # VAR=x VAR2=y git ...
|
||||||
|
m = ASSIGNMENT.match(seg)
|
||||||
|
if not m:
|
||||||
|
break
|
||||||
|
seg = seg[m.end():]
|
||||||
|
if re.match(r"git(\s|$)", seg):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_column(header: str):
|
||||||
|
"""Index of the 'Run' column in a markdown header row, or None."""
|
||||||
|
cells = [c.strip().strip("`*_ ").lower() for c in header.strip().strip("|").split("|")]
|
||||||
|
return cells.index("run") if "run" in cells else None
|
||||||
|
|
||||||
|
|
||||||
|
def check(path: str):
|
||||||
|
problems = []
|
||||||
|
try:
|
||||||
|
lines = open(path, encoding="utf-8").read().splitlines()
|
||||||
|
except (OSError, UnicodeDecodeError) as exc:
|
||||||
|
# Unreadable is an error, never a silent pass.
|
||||||
|
return [(0, f"could not read file: {exc}")]
|
||||||
|
|
||||||
|
fence = None # closing marker of the open fence, or None
|
||||||
|
fence_is_shell = False
|
||||||
|
run_col = None # active Run-column index, or None
|
||||||
|
pending_header = None
|
||||||
|
|
||||||
|
for n, raw in enumerate(lines, start=1):
|
||||||
|
if fence is not None:
|
||||||
|
if re.match(r"^\s*" + re.escape(fence) + r"\s*$", raw):
|
||||||
|
fence, fence_is_shell = None, False
|
||||||
|
continue
|
||||||
|
if fence_is_shell and MARKER not in raw:
|
||||||
|
if bare_git_in_shell(strip_shell_comment(raw)):
|
||||||
|
problems.append((n, raw.strip()))
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = FENCE_OPEN.match(raw)
|
||||||
|
if m:
|
||||||
|
fence = m.group("f")
|
||||||
|
fence_is_shell = m.group("info").lower() in SHELL_INFO
|
||||||
|
run_col, pending_header = None, None
|
||||||
|
continue
|
||||||
|
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped.startswith("|"):
|
||||||
|
run_col, pending_header = None, None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A markdown table: header row, delimiter row, then data rows.
|
||||||
|
if run_col is None:
|
||||||
|
if pending_header is not None and set(stripped) <= set("|-: "):
|
||||||
|
run_col = run_column(pending_header)
|
||||||
|
pending_header = None
|
||||||
|
else:
|
||||||
|
pending_header = stripped
|
||||||
|
run_col = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if MARKER in raw:
|
||||||
|
continue
|
||||||
|
cells = stripped.strip("|").split("|")
|
||||||
|
if run_col >= len(cells):
|
||||||
|
continue
|
||||||
|
cell = cells[run_col].strip()
|
||||||
|
# Only a cell that OPENS with a backticked command is a dispatch entry.
|
||||||
|
# A cell opening with prose is explanation, and explanation is clause 2.
|
||||||
|
if not cell.startswith("`"):
|
||||||
|
continue
|
||||||
|
opening = BACKTICKED.match(cell)
|
||||||
|
if opening and re.match(r"git(\s|$)", opening.group(1).strip()):
|
||||||
|
problems.append((n, opening.group(1).strip()))
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
failed = False
|
||||||
|
for path in sys.argv[1:]:
|
||||||
|
for line_no, text in check(path):
|
||||||
|
failed = True
|
||||||
|
print(
|
||||||
|
f"ADR-0023: {path}:{line_no}: executable git command is not prefixed with `rtk`: {text}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Fix: write `rtk git <subcommand>` (ADR-0023 clause 1). If this command must stay bare\n"
|
||||||
|
"because rtk rewrites output the skill parses, or because it is interactive (clause 3),\n"
|
||||||
|
"say so inline and put the literal string ADR-0023 on the same line to record the opt-out.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
@@ -224,6 +224,8 @@ cat > "$SUBJECT_SKILL_DIR/SKILL.md" <<'EOF'
|
|||||||
---
|
---
|
||||||
name: my-skill
|
name: my-skill
|
||||||
description: A short valid description. Do not use for anything else.
|
description: A short valid description. Do not use for anything else.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Do the thing.
|
Do the thing.
|
||||||
|
|||||||
224
tests/test-check-rtk-prefix.sh
Normal file
224
tests/test-check-rtk-prefix.sh
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Tests for scripts/check-rtk-prefix.sh — ADR-0023 clause 1.
|
||||||
|
#
|
||||||
|
# Case 1 is the one that earns the rest: it runs the gate against the corpus as
|
||||||
|
# it stood on `main` BEFORE the #113 sweep, and asserts it fails there. A gate
|
||||||
|
# that only passes on the already-fixed tree proves nothing about whether it
|
||||||
|
# would have caught the drift it was written for.
|
||||||
|
#
|
||||||
|
# Everything after that is synthetic. The false-NEGATIVE cases (a bare command
|
||||||
|
# the gate must catch) and the false-POSITIVE cases (correct content the gate
|
||||||
|
# must leave alone) carry equal weight: this hook's failure mode is not missing
|
||||||
|
# a violation, it is firing on deliberately-bare clause-2 and clause-3 content
|
||||||
|
# until someone adds it to SKIP.
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SCRIPT="$REPO_ROOT/scripts/check-rtk-prefix.sh"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||||
|
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: python3 is not installed — the script under test fails closed on it, so every case here would only re-assert the missing-dependency guard"
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN_TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$RUN_TMP"' EXIT
|
||||||
|
|
||||||
|
# Writes $2 to a .md file and asserts the gate's verdict. $1 is "clean" or
|
||||||
|
# "dirty"; $3 is the case description.
|
||||||
|
expect() {
|
||||||
|
local want="$1" body="$2" desc="$3"
|
||||||
|
local f="$RUN_TMP/case.md"
|
||||||
|
printf '%s\n' "$body" > "$f"
|
||||||
|
local rc=0
|
||||||
|
bash "$SCRIPT" "$f" > "$RUN_TMP/out" 2>&1 || rc=$?
|
||||||
|
if [[ "$want" == "dirty" && $rc -eq 0 ]]; then
|
||||||
|
fail "$desc — expected a violation, exited 0"
|
||||||
|
elif [[ "$want" == "clean" && $rc -ne 0 ]]; then
|
||||||
|
fail "$desc — expected no violation, exited $rc"
|
||||||
|
sed 's/^/ /' "$RUN_TMP/out"
|
||||||
|
else
|
||||||
|
pass "$desc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "--- the pre-#113 corpus on main trips the gate ---"
|
||||||
|
|
||||||
|
# Derived from git, not hardcoded: the point is "the drift this gate exists for",
|
||||||
|
# and a hardcoded path list goes stale the moment a file is renamed.
|
||||||
|
if ! git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then
|
||||||
|
echo "SKIP: no local 'main' ref — the historical-corpus case cannot be reconstructed"
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
|
|
||||||
|
PRE="$RUN_TMP/pre"
|
||||||
|
# A read loop, not the bash-4 array builtin: tests/test-vale-wrap.sh scans every
|
||||||
|
# script under tests/ for bash-4-only constructs, because these run on macOS's
|
||||||
|
# bash 3.2. Same reason for the `[@]+` guards on every array expansion below.
|
||||||
|
PRE_FILES=()
|
||||||
|
PRE_PATHS=()
|
||||||
|
while IFS= read -r PRE_REL; do
|
||||||
|
PRE_FILES+=("$PRE_REL")
|
||||||
|
PRE_PATHS+=("$PRE/$PRE_REL")
|
||||||
|
done < <(
|
||||||
|
git -C "$REPO_ROOT" ls-tree -r --name-only main -- 'plugins' \
|
||||||
|
| grep -E '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$' \
|
||||||
|
| grep -v '/README\.md$'
|
||||||
|
)
|
||||||
|
if [[ ${#PRE_FILES[@]} -eq 0 ]]; then
|
||||||
|
fail "no plugin skill/agent files found on main — the historical case checked nothing"
|
||||||
|
else
|
||||||
|
for f in ${PRE_FILES[@]+"${PRE_FILES[@]}"}; do
|
||||||
|
mkdir -p "$PRE/$(dirname "$f")"
|
||||||
|
git -C "$REPO_ROOT" show "main:$f" > "$PRE/$f"
|
||||||
|
done
|
||||||
|
if bash "$SCRIPT" ${PRE_PATHS[@]+"${PRE_PATHS[@]}"} > "$RUN_TMP/pre.out" 2>&1; then
|
||||||
|
fail "the pre-sweep corpus passed — the gate would not have caught the #113 drift"
|
||||||
|
else
|
||||||
|
hits="$(grep -c '^ADR-0023: ' "$RUN_TMP/pre.out" || true)"
|
||||||
|
if [[ "$hits" -lt 20 ]]; then
|
||||||
|
fail "the pre-sweep corpus produced only $hits findings — too few to be the known drift"
|
||||||
|
elif ! grep -q 'gitea-issues/SKILL.md.*git remote get-url origin' "$RUN_TMP/pre.out"; then
|
||||||
|
fail "the pre-sweep corpus failed, but not on the known gitea drift"
|
||||||
|
sed 's/^/ /' "$RUN_TMP/pre.out" | head -5
|
||||||
|
else
|
||||||
|
pass "the pre-sweep corpus fails with $hits findings, including the gitea sweep gap"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- shell fences: what must fail ---"
|
||||||
|
|
||||||
|
expect dirty '```bash
|
||||||
|
git commit -m "x"
|
||||||
|
```' "a bare git command in a bash fence"
|
||||||
|
|
||||||
|
expect dirty '```sh
|
||||||
|
rtk git add -u && git commit -m "x"
|
||||||
|
```' "a bare git after && on a line that starts with rtk git"
|
||||||
|
|
||||||
|
expect dirty '```bash
|
||||||
|
SKIP=check-yaml git commit -m "x"
|
||||||
|
```' "a bare git behind an environment-variable prefix"
|
||||||
|
|
||||||
|
expect dirty '```bash
|
||||||
|
url=$(git config remote.origin.url)
|
||||||
|
```' "a bare git inside a command substitution"
|
||||||
|
|
||||||
|
expect dirty '```console
|
||||||
|
$ git status
|
||||||
|
```' "a bare git behind a copied shell prompt"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- shell fences: what must NOT fail ---"
|
||||||
|
|
||||||
|
expect clean '```bash
|
||||||
|
rtk git commit -m "x"
|
||||||
|
rtk git push
|
||||||
|
```' "prefixed commands"
|
||||||
|
|
||||||
|
expect clean '```bash
|
||||||
|
git stash list # bare per ADR-0023: rtk prints "No stashes" where git prints nothing
|
||||||
|
```' "a bare command carrying the ADR-0023 opt-out marker"
|
||||||
|
|
||||||
|
expect clean '```text
|
||||||
|
git worktree list --porcelain -z
|
||||||
|
```' "a non-shell fence (text) is out of scope"
|
||||||
|
|
||||||
|
expect clean '```yaml
|
||||||
|
entry: git
|
||||||
|
```' "a yaml fence is out of scope"
|
||||||
|
|
||||||
|
expect clean '```bash
|
||||||
|
# never reach for git commit --no-verify here
|
||||||
|
rtk git commit
|
||||||
|
```' "a bare git inside a shell comment"
|
||||||
|
|
||||||
|
expect clean 'Run `git switch <branch>` — no wait, this is prose, not a fence.' \
|
||||||
|
"a bare git in a prose line outside any fence"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- dispatch tables ---"
|
||||||
|
|
||||||
|
expect dirty '| Operation | Run |
|
||||||
|
|---|---|
|
||||||
|
| List | `git worktree list -v` |' "a bare command opening a Run cell"
|
||||||
|
|
||||||
|
expect clean '| Operation | Run |
|
||||||
|
|---|---|
|
||||||
|
| List | `rtk git worktree list -v` |' "a prefixed command in a Run cell"
|
||||||
|
|
||||||
|
expect clean '| Operation | Run |
|
||||||
|
|---|---|
|
||||||
|
| List | `git worktree list -v` — bare per ADR-0023 |' \
|
||||||
|
"a bare Run cell carrying the opt-out marker"
|
||||||
|
|
||||||
|
# The clause-2 shapes that made an every-span check unusable. Both are real rows
|
||||||
|
# from git-worktrees/SKILL.md.
|
||||||
|
expect clean '| Operation | Run |
|
||||||
|
|---|---|
|
||||||
|
| Track | `rtk git worktree add --track -b <b> <p> <r>/<b>` — always correct. `git worktree add <p> <b>` expands to exactly this |' \
|
||||||
|
"a referential bare mention AFTER the instructed command in a Run cell"
|
||||||
|
|
||||||
|
expect clean '| Operation | Run |
|
||||||
|
|---|---|
|
||||||
|
| **Never** `git worktree add <p> <r>/<b>` | That ref resolves, so `git push` needs an explicit refspec |' \
|
||||||
|
"an anti-pattern row whose Run cell opens with prose"
|
||||||
|
|
||||||
|
expect clean '| Flag | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `-L` | as in `git log -L` |' "a table with no Run column is out of scope"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- degenerate inputs ---"
|
||||||
|
|
||||||
|
expect clean '' "an empty file"
|
||||||
|
|
||||||
|
expect clean '```bash
|
||||||
|
rtk git status' "an unterminated fence does not crash the parser"
|
||||||
|
|
||||||
|
if bash "$SCRIPT" > "$RUN_TMP/noargs.out" 2>&1; then
|
||||||
|
pass "no filenames exits 0 rather than erroring"
|
||||||
|
else
|
||||||
|
fail "no filenames should exit 0 — pre-commit calls hooks with an empty file list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if bash "$SCRIPT" "$RUN_TMP/does-not-exist.md" > "$RUN_TMP/missing.out" 2>&1; then
|
||||||
|
fail "an unreadable file exited 0 — unreadable must be an error, never a silent pass"
|
||||||
|
elif ! grep -q 'could not read file' "$RUN_TMP/missing.out"; then
|
||||||
|
fail "an unreadable file failed for the wrong reason"
|
||||||
|
sed 's/^/ /' "$RUN_TMP/missing.out"
|
||||||
|
else
|
||||||
|
pass "an unreadable file exits 1 and says so"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- the live corpus is clean ---"
|
||||||
|
|
||||||
|
LIVE=()
|
||||||
|
while IFS= read -r LIVE_REL; do
|
||||||
|
LIVE+=("$LIVE_REL")
|
||||||
|
done < <(
|
||||||
|
git -C "$REPO_ROOT" ls-files -- 'plugins' \
|
||||||
|
| grep -E '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$' \
|
||||||
|
| grep -v '/README\.md$'
|
||||||
|
)
|
||||||
|
if [[ ${#LIVE[@]} -eq 0 ]]; then
|
||||||
|
fail "no plugin skill/agent files matched the hook's files: pattern"
|
||||||
|
elif (cd "$REPO_ROOT" && bash "$SCRIPT" ${LIVE[@]+"${LIVE[@]}"} > "$RUN_TMP/live.out" 2>&1); then
|
||||||
|
pass "the ${#LIVE[@]} in-scope corpus files pass"
|
||||||
|
else
|
||||||
|
fail "the live corpus has ADR-0023 clause-1 violations"
|
||||||
|
sed 's/^/ /' "$RUN_TMP/live.out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[[ $FAIL -eq 0 ]]
|
||||||
366
tests/test-skill-frontmatter.sh
Normal file
366
tests/test-skill-frontmatter.sh
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression test for the `skill-frontmatter` pre-commit hook.
|
||||||
|
#
|
||||||
|
# The hook is `entry: bash` with `args: ['-c', <script>, <arg0>]`, and this file
|
||||||
|
# drives that exact call shape -- read out of .pre-commit-config.yaml, never
|
||||||
|
# re-implemented here. That is the whole point. `grep -rn skill-frontmatter
|
||||||
|
# tests/` returned nothing before this file existed, and the two defect classes
|
||||||
|
# below are both invisible to a test that copies the script body and calls it
|
||||||
|
# some other way:
|
||||||
|
#
|
||||||
|
# 1. THE POSITIONAL DROP. `bash -c <script> fileA fileB` puts fileA in $0, not
|
||||||
|
# in "$@". The hook had no arg0 placeholder, so pre-commit's FIRST filename
|
||||||
|
# was swallowed -- and a single-file commit, the normal case, ran the loop
|
||||||
|
# body zero times and reported Passed having checked nothing. A test that
|
||||||
|
# sources or inlines the script never sees this; only the real invocation
|
||||||
|
# shape does. Cases 2 and 3 below are that regression.
|
||||||
|
#
|
||||||
|
# 2. THE UNSCOPED GREP. The checks used to run over the WHOLE file, and the
|
||||||
|
# version check was `grep -A10 "^metadata:" | grep -q " version:"`. Four
|
||||||
|
# confirmed ways to pass while measuring nothing, all pinned below:
|
||||||
|
# * `metadata:` in a BODY code fence satisfies it (skill-author's own
|
||||||
|
# docs quote exactly such a block);
|
||||||
|
# * `-A10` runs past the end of the metadata block, so a `version:`
|
||||||
|
# belonging to a following `source:` list entry satisfies it
|
||||||
|
# (write-docs and research both have `source:` right after `metadata:`);
|
||||||
|
# * `" version:"` is an unanchored substring, so ` version:` at a
|
||||||
|
# deeper nesting satisfies it;
|
||||||
|
# * and the mirror image, a false NEGATIVE: a metadata block with more
|
||||||
|
# than 10 lines before `version:` was reported missing.
|
||||||
|
#
|
||||||
|
# Plus the semver assertion, which is not redundant with presence: write-docs
|
||||||
|
# carried `version: "1.0"` -- present, well-nested, and not a version -- through
|
||||||
|
# an entire PR under a check that only ever asked whether the key was there.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
CONFIG="$REPO_ROOT/.pre-commit-config.yaml"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||||
|
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
# Same guard shape as tests/test-check-executables-allow-sync.sh: the hook
|
||||||
|
# definition is YAML and reading it any other way is guessing. README.md lists
|
||||||
|
# python3/PyYAML as a pre-push prerequisite, and `run-tests.sh --strict` turns
|
||||||
|
# this skip into a failure, which is the correct reading when it runs as a gate.
|
||||||
|
command -v python3 > /dev/null 2>&1 || { echo "SKIP: python3 is required to read the hook definition out of .pre-commit-config.yaml"; exit 77; }
|
||||||
|
python3 -c 'import yaml' > /dev/null 2>&1 || { echo "SKIP: PyYAML is required to read the hook definition out of .pre-commit-config.yaml"; exit 77; }
|
||||||
|
|
||||||
|
TMPDIR_T="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMPDIR_T"' EXIT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The hook, as pre-commit will run it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ENTRY and ARGS come straight out of the config. run_hook() then reproduces
|
||||||
|
# pre-commit's own composition -- entry, then args, then the filenames appended
|
||||||
|
# LAST -- so the positional handling under test is the real one.
|
||||||
|
HOOK_JSON="$TMPDIR_T/hook.json"
|
||||||
|
python3 - "$CONFIG" "$HOOK_JSON" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path, out_path = sys.argv[1:3]
|
||||||
|
with open(config_path, encoding='utf-8') as fh:
|
||||||
|
cfg = yaml.safe_load(fh) or {}
|
||||||
|
|
||||||
|
found = None
|
||||||
|
for repo in cfg.get('repos') or []:
|
||||||
|
for hook in repo.get('hooks') or []:
|
||||||
|
if hook.get('id') == 'skill-frontmatter':
|
||||||
|
found = hook
|
||||||
|
|
||||||
|
if found is None:
|
||||||
|
sys.exit("skill-frontmatter hook not found in " + config_path)
|
||||||
|
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as fh:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
'entry': found.get('entry'),
|
||||||
|
'args': found.get('args') or [],
|
||||||
|
'files': found.get('files'),
|
||||||
|
'pass_filenames': found.get('pass_filenames', True),
|
||||||
|
'always_run': found.get('always_run', False),
|
||||||
|
},
|
||||||
|
fh,
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Reads NUL-delimited fields on stdin into the named array. bash 3.2 (macOS) has
|
||||||
|
# no `mapfil[e]`/`readarra[y]` builtin, and tests/test-vale-wrap.sh's static scan
|
||||||
|
# rejects both — the bracket classes above match what a bare spelling would while
|
||||||
|
# keeping one out of this file, the same trick that file uses for `npro[c]`.
|
||||||
|
read_nul_array() {
|
||||||
|
local __var="$1"
|
||||||
|
shift
|
||||||
|
local __item
|
||||||
|
eval "$__var=()"
|
||||||
|
while IFS= read -r -d '' __item; do
|
||||||
|
eval "$__var+=(\"\$__item\")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTRY="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["entry"])' "$HOOK_JSON")"
|
||||||
|
ARGS=()
|
||||||
|
read_nul_array ARGS < <(python3 -c '
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
data = json.load(open(sys.argv[1]))
|
||||||
|
for arg in data["args"]:
|
||||||
|
sys.stdout.write(arg + "\0")
|
||||||
|
' "$HOOK_JSON")
|
||||||
|
|
||||||
|
RUN_OUT=""
|
||||||
|
RUN_RC=0
|
||||||
|
# run_hook <file>... -- exactly `entry args... files...`, pre-commit's ordering.
|
||||||
|
run_hook() {
|
||||||
|
RUN_RC=0
|
||||||
|
RUN_OUT="$("$ENTRY" ${ARGS[@]+"${ARGS[@]}"} "$@" 2>&1)" || RUN_RC=$?
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_skill <path> <frontmatter-body> [markdown-body]
|
||||||
|
write_skill() {
|
||||||
|
local path="$1" frontmatter="$2" body="${3:-# Heading
|
||||||
|
|
||||||
|
Body text.}"
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
{
|
||||||
|
printf -- '---\n'
|
||||||
|
printf '%s\n' "$frontmatter"
|
||||||
|
printf -- '---\n\n'
|
||||||
|
printf '%s\n' "$body"
|
||||||
|
} > "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_FM='name: valid-skill
|
||||||
|
description: A skill whose frontmatter is complete and well formed.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"'
|
||||||
|
|
||||||
|
assert_passes() { # <label> <file>...
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
run_hook "$@"
|
||||||
|
if [[ $RUN_RC -eq 0 ]]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label -- expected exit 0, got $RUN_RC. Output: $RUN_OUT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_fails_with() { # <label> <needle> <file>...
|
||||||
|
local label="$1" needle="$2"
|
||||||
|
shift 2
|
||||||
|
run_hook "$@"
|
||||||
|
if [[ $RUN_RC -eq 0 ]]; then
|
||||||
|
fail "$label -- exited 0, so the hook reported Passed having checked nothing. Output: $RUN_OUT"
|
||||||
|
elif [[ "$RUN_OUT" != *"$needle"* ]]; then
|
||||||
|
fail "$label -- exited $RUN_RC but the message lacked '$needle'. Output: $RUN_OUT"
|
||||||
|
else
|
||||||
|
pass "$label"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. The call shape itself
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Asserted as a contract as well as behaviourally, because the behavioural
|
||||||
|
# symptom of losing arg0 is a GREEN run -- the least likely thing to be noticed.
|
||||||
|
echo "--- the hook passes an arg0 placeholder so pre-commit's filenames land in \"\$@\" ---"
|
||||||
|
if [[ "$ENTRY" != "bash" ]]; then
|
||||||
|
fail "entry is '$ENTRY', not bash -- the arg0 reasoning below assumes bash -c"
|
||||||
|
elif [[ ${#ARGS[@]} -lt 3 ]]; then
|
||||||
|
fail "args has ${#ARGS[@]} entries; a 'bash -c <script>' hook needs a third, the arg0 placeholder, or the first filename is dropped from \"\$@\""
|
||||||
|
elif [[ "${ARGS[0]}" != "-c" ]]; then
|
||||||
|
fail "args[0] is '${ARGS[0]}', not -c"
|
||||||
|
elif [[ "${ARGS[2]}" == -* ]]; then
|
||||||
|
fail "args[2] is '${ARGS[2]}', which bash will read as a flag rather than as \$0"
|
||||||
|
else
|
||||||
|
pass "args is [-c, <script>, '${ARGS[2]}'] -- the third entry becomes \$0 and every filename reaches \"\$@\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILES_PATTERN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["files"] or "")' "$HOOK_JSON")"
|
||||||
|
PASS_FILENAMES="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pass_filenames"])' "$HOOK_JSON")"
|
||||||
|
echo ""
|
||||||
|
echo "--- the hook is filename-driven, so the loop is the only thing that ever runs ---"
|
||||||
|
if [[ "$PASS_FILENAMES" != "True" ]]; then
|
||||||
|
fail "pass_filenames is $PASS_FILENAMES; with no filenames the loop body never executes and the hook is a permanent no-op"
|
||||||
|
elif [[ "$FILES_PATTERN" != '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' ]]; then
|
||||||
|
fail "files: is '$FILES_PATTERN', not the .apm/ skill scope -- ADR-0020's gates and this one must agree on scope"
|
||||||
|
else
|
||||||
|
pass "pass_filenames is on and files: is the .apm/ skill scope"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. A single bad file, on its own -- THE regression
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# This is the shipped bug in one line: one file, which is what a commit
|
||||||
|
# touching one skill hands the hook. Before the arg0 fix the file landed in $0,
|
||||||
|
# "$@" was empty, and this exited 0.
|
||||||
|
echo ""
|
||||||
|
echo "--- a SINGLE bad file fails (before the arg0 fix this exited 0 having read nothing) ---"
|
||||||
|
write_skill "$TMPDIR_T/single/SKILL.md" 'name: single
|
||||||
|
description: Missing its metadata block entirely.'
|
||||||
|
assert_fails_with "one file with no metadata block is rejected" "missing required frontmatter fields" "$TMPDIR_T/single/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- and a single GOOD file still passes, so the case above is not failing for some other reason ---"
|
||||||
|
write_skill "$TMPDIR_T/single-ok/SKILL.md" "$VALID_FM"
|
||||||
|
assert_passes "one valid file passes" "$TMPDIR_T/single-ok/SKILL.md"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Position within the argument list must not matter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "--- the bad file is caught wherever it sits in the argument list ---"
|
||||||
|
write_skill "$TMPDIR_T/ok-a/SKILL.md" "$VALID_FM"
|
||||||
|
write_skill "$TMPDIR_T/ok-b/SKILL.md" "$VALID_FM"
|
||||||
|
write_skill "$TMPDIR_T/bad/SKILL.md" 'name: bad
|
||||||
|
description: No metadata block.'
|
||||||
|
|
||||||
|
assert_fails_with "bad file FIRST is caught" "$TMPDIR_T/bad/SKILL.md" \
|
||||||
|
"$TMPDIR_T/bad/SKILL.md" "$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md"
|
||||||
|
assert_fails_with "bad file LAST is caught (the loop reaches the end of \"\$@\")" "$TMPDIR_T/bad/SKILL.md" \
|
||||||
|
"$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md" "$TMPDIR_T/bad/SKILL.md"
|
||||||
|
assert_passes "three valid files pass" "$TMPDIR_T/ok-a/SKILL.md" "$TMPDIR_T/ok-b/SKILL.md" "$TMPDIR_T/single-ok/SKILL.md"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. The four grep defects
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "--- defect 1: a \`metadata:\` block in a BODY code fence is documentation, not frontmatter ---"
|
||||||
|
# skill-author's docs quote a metadata block verbatim. Under the old whole-file
|
||||||
|
# grep that quotation satisfied the check for the file quoting it.
|
||||||
|
write_skill "$TMPDIR_T/fence/SKILL.md" 'name: fence
|
||||||
|
description: Frontmatter has no metadata block; the body quotes one.' '# Fence
|
||||||
|
|
||||||
|
Skills declare their version like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
```'
|
||||||
|
assert_fails_with "a quoted metadata block in the body does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/fence/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- ... and the same for name: and description: quoted in the body ---"
|
||||||
|
write_skill "$TMPDIR_T/fence-keys/SKILL.md" 'metadata:
|
||||||
|
version: "1.0.0"' '# Fence keys
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: not-the-real-name
|
||||||
|
description: not the real description
|
||||||
|
```'
|
||||||
|
assert_fails_with "body-fenced name:/description: do not satisfy the presence checks" "name: description:" "$TMPDIR_T/fence-keys/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- defect 2: a \`version:\` under a following \`source:\` list is not metadata.version ---"
|
||||||
|
# `-A10` ran ten lines past `metadata:` regardless of where the block ended.
|
||||||
|
# write-docs and research both carry a `source:` list immediately after it.
|
||||||
|
write_skill "$TMPDIR_T/source-list/SKILL.md" 'name: source-list
|
||||||
|
description: metadata has no version; the next top-level key does.
|
||||||
|
metadata:
|
||||||
|
author: someone
|
||||||
|
source:
|
||||||
|
- name: upstream
|
||||||
|
version: "2.3.4"'
|
||||||
|
assert_fails_with "a version: belonging to source[] does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/source-list/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- defect 3: a deeper-nested \` version:\` is not metadata.version ---"
|
||||||
|
# `grep -q " version:"` was an unanchored substring match, so any indentation
|
||||||
|
# of two spaces or more matched.
|
||||||
|
write_skill "$TMPDIR_T/deep-indent/SKILL.md" 'name: deep-indent
|
||||||
|
description: The only version: key sits one level too deep.
|
||||||
|
metadata:
|
||||||
|
provenance:
|
||||||
|
version: "1.0.0"'
|
||||||
|
assert_fails_with "a four-space-indented version: does not satisfy metadata.version" "missing required frontmatter fields" "$TMPDIR_T/deep-indent/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- defect 4: a version: more than ten lines into the metadata block is FOUND ---"
|
||||||
|
# The mirror image: the old check reported this one missing.
|
||||||
|
write_skill "$TMPDIR_T/long-metadata/SKILL.md" 'name: long-metadata
|
||||||
|
description: A long metadata block whose version sits well past line ten.
|
||||||
|
metadata:
|
||||||
|
a: 1
|
||||||
|
b: 2
|
||||||
|
c: 3
|
||||||
|
d: 4
|
||||||
|
e: 5
|
||||||
|
f: 6
|
||||||
|
g: 7
|
||||||
|
h: 8
|
||||||
|
i: 9
|
||||||
|
j: 10
|
||||||
|
k: 11
|
||||||
|
version: "1.0.0"'
|
||||||
|
assert_passes "a version: 13 lines into the metadata block is found" "$TMPDIR_T/long-metadata/SKILL.md"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Present is not the same as well formed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "--- a present-but-non-semver version is rejected, with its own message ---"
|
||||||
|
# plugins/bin/.apm/skills/write-docs/SKILL.md carried exactly this through a
|
||||||
|
# whole PR: the key was present, so a presence-only check had nothing to say.
|
||||||
|
write_skill "$TMPDIR_T/two-part/SKILL.md" 'name: two-part
|
||||||
|
description: Its version is two-part, which is a float in YAML, not a version.
|
||||||
|
metadata:
|
||||||
|
version: "1.0"'
|
||||||
|
assert_fails_with "\"1.0\" is rejected as malformed, not reported as missing" "malformed frontmatter metadata.version" "$TMPDIR_T/two-part/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- ... and the malformed message quotes the offending value ---"
|
||||||
|
run_hook "$TMPDIR_T/two-part/SKILL.md"
|
||||||
|
if [[ "$RUN_OUT" == *'"1.0"'* ]]; then
|
||||||
|
pass "the message names the value it rejected"
|
||||||
|
else
|
||||||
|
fail "the message did not quote the rejected value. Output: $RUN_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- other non-semver shapes ---"
|
||||||
|
write_skill "$TMPDIR_T/empty-version/SKILL.md" 'name: empty-version
|
||||||
|
description: The key is present with no value at all.
|
||||||
|
metadata:
|
||||||
|
version:'
|
||||||
|
assert_fails_with "a valueless version: is rejected" "metadata.version" "$TMPDIR_T/empty-version/SKILL.md"
|
||||||
|
|
||||||
|
write_skill "$TMPDIR_T/word-version/SKILL.md" 'name: word-version
|
||||||
|
description: A non-numeric version.
|
||||||
|
metadata:
|
||||||
|
version: latest'
|
||||||
|
assert_fails_with "version: latest is rejected" "malformed frontmatter metadata.version" "$TMPDIR_T/word-version/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- an unquoted three-part version is accepted (both YAML spellings are legal) ---"
|
||||||
|
write_skill "$TMPDIR_T/unquoted/SKILL.md" 'name: unquoted
|
||||||
|
description: An unquoted semver, which YAML reads as a string.
|
||||||
|
metadata:
|
||||||
|
version: 0.1.4'
|
||||||
|
assert_passes "version: 0.1.4 unquoted passes" "$TMPDIR_T/unquoted/SKILL.md"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. A file that cannot be read as frontmatter must not report green
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "--- a file with no frontmatter block fails loudly rather than passing vacuously ---"
|
||||||
|
printf '# Just a document\n\nname: not-frontmatter\ndescription: nor this\n' > "$TMPDIR_T/no-fm.md"
|
||||||
|
assert_fails_with "a file with no --- block is an error" "no closing YAML frontmatter block" "$TMPDIR_T/no-fm.md"
|
||||||
|
|
||||||
|
printf -- '---\nname: unterminated\ndescription: the block is never closed\nmetadata:\n version: "1.0.0"\n' > "$TMPDIR_T/unterminated.md"
|
||||||
|
assert_fails_with "an unterminated frontmatter block is an error" "no closing YAML frontmatter block" "$TMPDIR_T/unterminated.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- a path that does not exist is skipped, not crashed on ---"
|
||||||
|
assert_passes "a nonexistent path is ignored" "$TMPDIR_T/nope/SKILL.md"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[[ $FAIL -eq 0 ]]
|
||||||
Reference in New Issue
Block a user