Compare commits
22 Commits
v1.0.0
...
fddf39e396
| Author | SHA1 | Date | |
|---|---|---|---|
| fddf39e396 | |||
| ad5d61430c | |||
| 8999db18e7 | |||
| 481fedcbca | |||
| 77c722ea5c | |||
| 8a154dacbe | |||
| 7941c57559 | |||
| 1a267d7964 | |||
| e361498311 | |||
| 198fa9a0f6 | |||
| 547a653fa0 | |||
| bc784d697b | |||
| 05e063bdfc | |||
| fdeaf741a8 | |||
| b2240864b2 | |||
| 3894c33799 | |||
| 35e9a22ae6 | |||
| 40f4d32175 | |||
| 47a26b5ee2 | |||
| 29af47db20 | |||
| b85cba2abe | |||
| 99403f3596 |
@@ -38,12 +38,7 @@
|
||||
"repo": "mattpocock/skills",
|
||||
"source": "github"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"name": "lint",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
],
|
||||
"version": "0.3.1"
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"bin@holocron": true,
|
||||
"core@holocron": true,
|
||||
"git@holocron": true,
|
||||
"gitea@holocron": true,
|
||||
"kyberforge@holocron": true
|
||||
},
|
||||
"hooks": {
|
||||
|
||||
7
.github/plugin/marketplace.json
vendored
7
.github/plugin/marketplace.json
vendored
@@ -38,12 +38,7 @@
|
||||
"repo": "mattpocock/skills",
|
||||
"source": "github"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"name": "lint",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
],
|
||||
"version": "0.3.1"
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
||||
@@ -61,24 +61,6 @@ repos:
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: check-vale-style-sync
|
||||
name: Check Vale style copies are in sync
|
||||
description: Diff skill-audit's Vale copy against agent-audit's canonical copy
|
||||
entry: bash scripts/check-vale-style-sync.sh
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: check-release-needed
|
||||
name: Check a release tag covers .pre-commit-hooks.yaml's paths
|
||||
description: On push to main only, fail if files exposed via .pre-commit-hooks.yaml changed since the last tag
|
||||
entry: bash scripts/check-release-needed.sh
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: validate-plugins
|
||||
name: Validate plugins
|
||||
description: Run claude plugin validate --strict on every plugin directory
|
||||
@@ -116,33 +98,6 @@ repos:
|
||||
fi
|
||||
done
|
||||
|
||||
- id: skill-size-check
|
||||
stages: ['pre-commit']
|
||||
name: SKILL.md size ceiling
|
||||
description: Enforce agentskills.io's 500-line/5,000-token SKILL.md size ceiling
|
||||
entry: scripts/skill-size-check.sh
|
||||
language: script
|
||||
files: '^plugins/[^/]+/skills/[^/]+/SKILL\.md$'
|
||||
pass_filenames: true
|
||||
|
||||
- id: vale-audit-prefilter-skill
|
||||
stages: ['pre-commit']
|
||||
name: Vale audit prefilter (SKILL.md)
|
||||
description: Run Vale against SKILL.md files as a deterministic prefilter for skill-audit, via skill-audit's own bundled copy
|
||||
entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '^plugins/[^/]+/skills/[^/]+/SKILL\.md$'
|
||||
pass_filenames: true
|
||||
|
||||
- id: vale-audit-prefilter-agent
|
||||
stages: ['pre-commit']
|
||||
name: Vale audit prefilter (agent files)
|
||||
description: Run Vale against agent markdown files as a deterministic prefilter for agent-audit, via agent-audit's own bundled copy
|
||||
entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '^plugins/[^/]+/agents/[^/]+\.md$'
|
||||
pass_filenames: true
|
||||
|
||||
- repo: meta
|
||||
hooks:
|
||||
- id: check-hooks-apply
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
- id: kyberforge-vale-audit-skill
|
||||
name: Kyberforge Vale prose audit (SKILL.md)
|
||||
description: Deterministic prose-pattern prefilter for kyberforge's skill-audit, via its own bundled Vale config/styles
|
||||
entry: plugins/kyberforge/skills/skill-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '(^|/)SKILL\.md$'
|
||||
|
||||
- id: kyberforge-vale-audit-agent
|
||||
name: Kyberforge Vale prose audit (agent files)
|
||||
description: Deterministic prose-pattern prefilter for kyberforge's agent-audit, via its own bundled Vale config/styles
|
||||
entry: plugins/kyberforge/skills/agent-audit/scripts/vale-wrap.sh
|
||||
language: script
|
||||
files: '(^|/)agents/[^/]+\.md$|\.agent\.md$'
|
||||
|
||||
- id: kyberforge-skill-size-check
|
||||
name: SKILL.md size ceiling
|
||||
description: Enforce agentskills.io's 500-line/5,000-token SKILL.md size ceiling
|
||||
entry: scripts/skill-size-check.sh
|
||||
language: script
|
||||
files: '(^|/)SKILL\.md$'
|
||||
34
AGENTS.md
34
AGENTS.md
@@ -1,41 +1,33 @@
|
||||
# Working in this repo
|
||||
|
||||
This repo is the global AI development configuration repository — the authoritative source for agent definitions, skills, workflows, and prompts across all projects. Built as a homelab tool intended to scale to professional environments.
|
||||
This repo is the global AI development configuration repository — the authoritative source for agent definitions, skills, workflows, and prompts across all projects.
|
||||
|
||||
## Structure
|
||||
|
||||
- `plugins/` — installable plugin units; each is self-contained (skills, agents, hooks, MCP servers, bundled assets); install separately via `claude plugin install <name>@holocron`
|
||||
- `providers/claude-code/` — Claude Code adapter (deployed to `~/.claude/` via `install.sh`)
|
||||
|
||||
## Prefer plugin skills over raw shell
|
||||
|
||||
This repo dogfoods its own plugins. Before shelling out to git, gitea, or lint tooling directly, check whether an installed skill already owns the operation — it usually does:
|
||||
|
||||
- Commits, branches, history, worktrees, remotes → `git:git-commits`, `git:git-branches`, `git:git-history`, `git:git-worktrees`, `git:git-remotes`
|
||||
- Pre-commit hook install/config/troubleshooting → `git:pc-run` / `git:pc-author`
|
||||
- Issues, PRs, labels, milestones → `gitea:gitea-issues`, `gitea:gitea-prs`, `gitea:gitea-labels-milestones`; also `gitea:gitea-branches`, `gitea:gitea-files`, `gitea:gitea-releases`, or `gitea:gitea-workflow` when the domain is ambiguous
|
||||
- Vale prose linting → `lint:vale-config` / `lint:vale-run`
|
||||
- This repo's own AGENTS.md → `core:agentsmd-author` / `core:agentsmd-audit`
|
||||
|
||||
Fall back to raw shell only when no skill covers it.
|
||||
|
||||
## Setup and testing
|
||||
|
||||
- Install git hooks via `git:pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (tests, manifest check).
|
||||
- Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, which run on every commit touching a `SKILL.md` or agent `.md` file. Without it the hooks fail with a bare "command not found" and no install pointer. `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or see https://vale.sh/docs/vale-cli/installation/. No `vale sync` needed — the `Kyberforge` styles are committed under `plugins/kyberforge/skills/{skill-audit,agent-audit}/assets/vale/styles/`, not downloaded packages (see ADR-0014).
|
||||
- Run `bash tests/run-tests.sh` before considering any change done — it runs every `test-*.sh` script in the repo plus the bats suite (`--bats-only` for just bats). First run auto-initializes the bats submodules; no manual `git submodule update` needed.
|
||||
- Pushing re-runs the full suite plus `scripts/check-manifests.sh` via the pre-push hook — same commands, so run them locally first.
|
||||
- Author commits with `git:git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you.
|
||||
|
||||
## Key documents
|
||||
|
||||
Read CONTEXT.md at the start of every session in this repo.
|
||||
|
||||
Read these on demand:
|
||||
|
||||
- `docs/VISION.md` — purpose, goals, and long-term Management Application vision
|
||||
- `docs/spec/architecture.md` — current directory structure, install pipeline, provider model
|
||||
- `docs/adr/` — architectural decisions; read before answering design questions or proposing structural changes
|
||||
- `docs/ai-constitution.md` — full governance evidence base; read when a governance decision needs justification
|
||||
- `docs/research/ai-coding-factory/ai-coding-factory-principles.md` — factory design rationale; read when implementing, auditing, or reviewing skills or factory structure
|
||||
- `docs/notes/factory-integration-decisions.md` — decisions from the factory integration grill; read when making skill authoring or factory design decisions
|
||||
- Governance rules are always in effect — `core/instructions/governance.md` (agent rules); `docs/research/governance_principles/CONTROLS.md`
|
||||
|
||||
## Working context
|
||||
|
||||
This repo is built by a junior developer as a homelab tool intended to scale to professional environments. Challenge ideas and reference industry standards rather than validate assumptions. Explain the why behind decisions — assume the user is learning, not just executing. Flag significant actions before taking them.
|
||||
|
||||
## Subagent orchestration
|
||||
|
||||
- **Forks stop when their assigned task is done.** A `fork` inherits the coordinator's full context, including visibility into any shared TaskList. That visibility is not license to keep going: once a fork's assigned task is reported complete, it must stop rather than autonomously picking up further items from the list. Forks that keep pulling work race against the coordinator's own orchestration and can duplicate or conflict with tasks the coordinator has separately delegated.
|
||||
- **Don't hand a fork a TaskList that includes governance-gated actions** (push, publish, merge) unless you are prepared for it to act on those items without a fresh confirmation round. A fork acting on its own initiative is not party to any pending human confirmation the coordinator is mid-flow on, so it can bypass a gate that was meant to hold.
|
||||
- **`TaskGet`/`TaskUpdate`/`TaskList` only work for forks.** Fresh (non-fork) subagents cannot discover or call these tools. When delegating to a fresh subagent, the coordinator owns all task-list bookkeeping itself — claim and complete the entry on the agent's behalf — rather than instructing the fresh agent to self-claim or self-complete.
|
||||
- **Worktree/branch cleanup is part of closing out the PR, not a separate step.** When a coordinator creates a worktree (`Agent(isolation: "worktree")` or `git worktree add` directly) to land a PR, merging that PR is not the end of the task. Immediately after verifying the merge: run `git worktree remove --force --force <path>` (the double `-f` is required whenever the worktree initialized submodules to run tests — assume it did, this repo has several), then `git branch -d` both the feature branch and any `worktree-agent-<id>` isolation branch the `Agent` tool auto-created for that worktree — `git worktree remove` deletes neither branch on its own. Do this without waiting for the user to notice stale branches/worktrees and ask.
|
||||
|
||||
22
CONTEXT.md
22
CONTEXT.md
@@ -8,7 +8,7 @@ description: Domain language and decisions for the global AI development config
|
||||
## Principles
|
||||
|
||||
### CLAUDE.md index model
|
||||
`AGENTS.md` is the source of always-on universal rules (provider-agnostic). `providers/claude-code/CLAUDE.md` is a thin adapter: it imports `~/.agents/AGENTS.md` via `@~/.agents/AGENTS.md` and appends Claude Code-specific additions (`@import` for governance.md, content index). Deployed to `~/.claude/CLAUDE.md` via `install.sh`. Context size is kept minimal — only what is needed every session is loaded upfront; detailed content is pulled on demand. See ADR-0003.
|
||||
`AGENTS.md` is the source of always-on universal rules (provider-agnostic). `providers/claude-code/CLAUDE.md` is a thin adapter: it imports `~/.agents/AGENTS.md` via `@~/.agents/AGENTS.md` and appends Claude Code-specific additions (`@import` for governance.md, content index). Deployed to `~/.claude/CLAUDE.md` via `install.sh`. Context size is kept minimal — only what is needed every session is loaded upfront; detailed content is pulled on demand. See ADR-0012.
|
||||
|
||||
### Instruction file format
|
||||
`core/instructions/<topic>.md` files are plain markdown — no frontmatter, no schema. The agent decides when to read each file based on task context and the content index label in `providers/claude-code/CLAUDE.md`. Frontmatter is deferred until there is evidence that agents are loading the wrong files in practice.
|
||||
@@ -46,10 +46,10 @@ The provider-agnostic always-on instruction entry point. Two files:
|
||||
- **Repo-level `AGENTS.md`** — instructions for agents working inside this repo (structure, key rules); imported by repo `CLAUDE.md` via `@AGENTS.md`.
|
||||
- **Global `core/AGENTS.md`** — Communication and Behavior rules that apply across all projects; deployed to `~/.agents/AGENTS.md`; imported by `~/.claude/CLAUDE.md` via `@~/.agents/AGENTS.md`.
|
||||
|
||||
Contains always-on rules in plain markdown with no provider-specific syntax (no `@import`). Provider-specific files (`CLAUDE.md`) are thin adapters that import the relevant `AGENTS.md` and add only Claude Code-specific syntax. This pattern means a single source of truth can serve multiple providers without duplication. See ADR-0003.
|
||||
Contains always-on rules in plain markdown with no provider-specific syntax (no `@import`). Provider-specific files (`CLAUDE.md`) are thin adapters that import the relevant `AGENTS.md` and add only Claude Code-specific syntax. This pattern means a single source of truth can serve multiple providers without duplication. See ADR-0012.
|
||||
|
||||
### Skill composition
|
||||
A skill calling another skill by name to delegate a sub-task. The calling skill focuses on the orchestration decision ("when to do X"); the called skill owns the mechanics ("how to do X"). Established compositions: `grill-me` calls `write-adr` when a decision crystallises; `implement-feature` calls `tdd` as its implementation methodology; `forge` calls `grill-with-docs` to refine intent, classifies the target artifact type (skill / agent / plugin / marketplace entry), then routes to the matching `*-author` skill — which owns its own create/improve logic and, where applicable, its own inline audit closeout (`skill-author` runs `/skill-audit`, `agent-author` runs `kyberforge:agent-audit`, both in the same context as the authoring work). Reserve `forge` for genuinely undecided "which artifact type is this" questions — an already-fully-specified corrective edit (exact file, line, and fix already known) should call the target author skill directly instead (`skill-author`, `plugin-author`, `agentsmd-author`, etc.); routing a known fix through `forge`'s grill-and-classify layer adds unnecessary indirection and, in practice, has been observed to lose track of hard constraints handed down the chain (e.g. "don't commit yet," "edit in this worktree") because each hop re-derives instructions from a shorter brief. `forge` additionally runs its own independent recheck after a skill/agent route finishes: a clean-context subagent (not forked, no inherited context) re-runs the same audit skill against the finished artifact, as a distinct verification layer from the author skill's inline audit — the two can share blind spots since the inline audit runs in the same context as the work it checks. If the clean audit surfaces any unresolved finding, `forge` loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved; only then is the route done. `plugin-author` and `marketplace-author` have no audit counterpart and get no recheck; their terminal check is `claude plugin validate`.
|
||||
A skill calling another skill by name to delegate a sub-task. The calling skill focuses on the orchestration decision ("when to do X"); the called skill owns the mechanics ("how to do X"). Established compositions: `grill-me` calls `write-adr` when a decision crystallises; `implement-feature` calls `tdd` as its implementation methodology; `forge` calls `grill-with-docs` to refine intent, classifies the target artifact type (skill / agent / plugin / marketplace entry), then routes to the matching `*-author` skill — which owns its own create/improve logic and, where applicable, its own inline audit closeout (`skill-author` runs `/skill-audit`, `agent-author` runs `kyberforge:agent-audit`, both in the same context as the authoring work). `forge` additionally runs its own independent recheck after a skill/agent route finishes: a clean-context subagent (not forked, no inherited context) re-runs the same audit skill against the finished artifact, as a distinct verification layer from the author skill's inline audit — the two can share blind spots since the inline audit runs in the same context as the work it checks. If the clean audit surfaces any unresolved finding, `forge` loops — re-invoke the author skill to resolve it, re-run the clean audit — until the clean audit comes back with nothing unresolved; only then is the route done. `plugin-author` and `marketplace-author` have no audit counterpart and get no recheck; their terminal check is `claude plugin validate`.
|
||||
|
||||
### Provider-agnostic issue tracker
|
||||
Skills and workflows reference "linked issue" generically rather than a specific provider. Gitea is the canonical issue tracker for this repo (see ADR-0017). "Issue" is the cross-provider term (GitHub, GitLab, Gitea all use it).
|
||||
@@ -60,21 +60,5 @@ The three-stage traceability record linking a skill back to its research inputs:
|
||||
### Bidirectional reference principle
|
||||
Files that reference other files should declare those references explicitly. The referencing file carries the forward reference (e.g. content index in `CLAUDE.md`, `references:` in frontmatter). The referenced file carries a `when:` field describing when it is loaded. Both sides should agree — divergence signals staleness. The reverse map ("what files reference this file?") is derived by a reference scanner script, not maintained manually. This principle applies to instruction files, skills, and workflow documents.
|
||||
|
||||
### agentsmd-author / agentsmd-audit
|
||||
A skill pair in the `core` plugin for writing, updating, and reviewing a repo's `AGENTS.md` file(s) — the generic open-standard file (see the `AGENTS.md` entry above), including this repo's own. `agentsmd-author` creates/updates AGENTS.md content, supports nested monorepo placement (per the standard's nearest-file-wins precedence), and closes out by invoking `agentsmd-audit` inline. `agentsmd-audit` runs a single combined pass checking three mandatory baselines: secrets/credentials (governance.md hard prohibition — AGENTS.md is committed content), structural completeness (common-sections checklist from the agents.md spec), and accuracy/drift (do referenced commands and paths actually resolve against the repo). `agentsmd-audit` never inspects provider adapter files (see `provider-adapter-author`) — its scope is AGENTS.md content only. Chosen over folding this into `kyberforge` because kyberforge's scope is meta-tooling for the holocron marketplace itself, not generic target-repo documentation; `core` is the intended home for cross-cutting, repo-agnostic utility skills.
|
||||
|
||||
### provider-adapter-author
|
||||
A companion skill (`core` plugin) that detects a target repo's provider-specific instruction file (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) and, where it duplicates content AGENTS.md should own, converts it into a thin adapter that imports AGENTS.md — mirroring this repo's own ADR-0002/ADR-0003 two-tier adapter pattern. Self-validates via its own bundled deterministic script (`scripts/validate-adapter.sh`: checks for an import reference, no duplicated headings, size threshold) rather than a separate paired audit skill — the check is mechanical, so a script suffices per governance.md's "prefer deterministic code for repeatable tasks." `agentsmd-author` calls this skill via skill composition when it detects an existing provider file with overlapping content.
|
||||
|
||||
### lint plugin
|
||||
A standalone, repo-agnostic plugin (`plugins/lint/`) for configuring and running linters — not scoped to kyberforge's own meta-tooling. First linter is Vale (prose style linting), split into two skills per the git/gitea per-concern pattern: `vale-config` (setup — `.vale.ini`, `StylesPath`, styles) and `vale-run` (invoke Vale, interpret/report findings). A `lint-runner` agent composes these for isolated-context lint sweeps; it is report-only (no `Edit` tool) — it flags findings, it does not rewrite prose. Vale's research docs (`docs/research/docs/vale/`) moved from `plugins/kyberforge/` to `plugins/lint/` to keep the provenance chain same-plugin.
|
||||
|
||||
### Vale audit prefilter (skill-audit / agent-audit)
|
||||
Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Description dimension (ADR motivation: issue #84) is repo-specific, not part of the generic `lint` plugin, so it doesn't live in `plugins/lint/` — but per ADR-0014 it also doesn't live at the repo root anymore. Two copies live inside `plugins/kyberforge/`, one per skill, since a plugin's cache-install only copies each skill's own files (no cross-skill sharing): `plugins/kyberforge/skills/agent-audit/assets/vale/` is canonical (`.vale.ini` plus a custom `Kyberforge` style covering description-opener banning ("This skill/agent..."), vague-capability wording ("helps with", "utilize", ...), and generic "see references/ for details" padding — and a `KyberforgeCopilot` style scoped only to `.agent.md` files for the Copilot-only "Use proactively has no effect" check), and `plugins/kyberforge/skills/skill-audit/assets/vale/` is a smaller duplicate (`Kyberforge` only, scoped to `SKILL.md`) kept in sync by `scripts/check-vale-style-sync.sh` (pre-push). A root-level `.pre-commit-hooks.yaml` exposes both copies (plus `skill-size-check`) so any external repo can enforce the same rules via `repo: <this-repo-url>, rev: <tag>` in its own `.pre-commit-config.yaml` — pre-commit clones the pinned rev into its own cache, independent of whether Claude Code or the `kyberforge` plugin is installed at all, and the same mechanism covers CI (`pre-commit run --all-files`). This repo's own `vale-audit-prefilter-skill`/`-agent` pre-commit hooks consume the identical plugin-bundled copies via `repo: local` (not a third root copy, and not a pinned self-reference — a pinned self-reference would lint working-tree edits against the last tagged release rather than the change being made). Every rule is `level: error` and every alert is a FAIL — no ignorable tier, same as shellcheck, the test suite, and conventional-pre-commit. Graded severities do not work here: Vale's exit code keys on `error` alerts alone, so `warning`/`suggestion` rules exit 0 and pre-commit swallows the output of a passing hook, leaving them invisible and blocking nothing. `MinAlertLevel` and `--minAlertLevel` are correspondingly absent from `.vale.ini` and the hook, being no-ops under this model. Vale covers the pattern-matchable sub-checks named in issue #84 (imperative opener, vague filler, `Use proactively`, generic reference-pointer padding) plus, per ADR-0013, one body-wide prose-pattern check ("There is/are" sentence openers) — everything else about body discipline (defaults-vs-menus, why-rationale, non-pattern-matchable judgment calls), near-miss exclusion strength, and control calibration stays LLM judgment.
|
||||
|
||||
Both skills' Step 1, and the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, call each copy's own `scripts/vale-wrap.sh` rather than `vale` directly — a workaround for a confirmed Vale 3.15.2 limitation (see `vale-config`'s Gotchas): `text.frontmatter.description` silently stops matching on most — not all — multi-line descriptions. Verified by reproduction, not assumed: `>` folded scalars, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars all yield 0 alerts and exit 0 on a deliberately-bad fixture, while a `|` literal block spanning the same 2+ lines lints normally (alerts fire, exit 1). The wrapper flattens those three broken forms to one physical line in a scratch copy (padding with blank lines so every other line number is unchanged) before handing off to real `vale`; `|` literal blocks and single-line descriptions pass through untouched, already linting correctly. The plain and quoted forms previously passed silently — unflattened and unmatched — so a bad description in either sailed through the prefilter. Handed no `--config` at all, the wrapper falls back to its own sibling `assets/vale/.vale.ini`, located from `${BASH_SOURCE[0]}` rather than from the cwd — which is why both manifests' `entry:` is now the bare script path with no argument after it. pre-commit prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`), so every later argument resolves against the *consuming* repo's root: a `--config` in `.pre-commit-hooks.yaml` pointed at a path no consumer has and hard-failed every external run with `E100 [--config] Runtime error`. `.pre-commit-config.yaml` drops the argument too, deliberately keeping the two entries identical — the local `repo: local` hook resolved its `--config` correctly only because the consuming repo *was* this repo, and that divergence is why three review rounds exercised a path no external consumer takes and missed the defect. An explicit `--config` still wins, in all three argv forms (`--config X`, `--config=/abs`, `--config=rel`), and a relative one still resolves against the caller's cwd, matching bare `vale`, not the repo root. Both audit skills' Step 1 now passes no `--config` either: it resolves the script relative to the skill's own directory so the call works from an installed plugin cache, but a relative `--config` alongside it would still resolve against the cwd, yielding `E100 Runtime error ... does not exist` and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades to full LLM judgment. `tests/test-vale-wrap.sh` regression-tests this against skill-audit's copy specifically (its fixtures are all `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has that glob section). Each `.vale.ini`'s section globs are path-agnostic (`[**/SKILL.md]` for skill-audit's copy; `[**/agents/*.md]`/`[**/*.agent.md]` for agent-audit's) and do no scoping on their own: Vale's `*` crosses `/`. Scoping comes from each pre-commit hook's own `files:` regex and from the audit skills passing one explicit file per invocation. The two manifests scope differently on purpose: this repo's `.pre-commit-config.yaml` pins its own layout — `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` for `-skill`, `^plugins/[^/]+/agents/[^/]+\.md$` for `-agent` — while the shipped `.pre-commit-hooks.yaml` stays layout-agnostic for external consumers whose skills live anywhere, using `(^|/)SKILL\.md$` and `(^|/)agents/[^/]+\.md$|\.agent\.md$`. Both manifests split the prefilter into two hooks precisely because one combined hook pointed at only one copy would silently 0-file-skip the other file type. A `SKILL.md` outside `plugins/` (e.g. project-scope `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted normally — the globs constrain filename shape, not location. Vale reports 0 files only when the path it is handed matches no glob section at all: a differently-named file, or a directory argument holding nothing that matches. That run prints `✔ 0 errors ... in 0 files.` and exits 0, indistinguishable from a clean pass, so both audits treat a 0-file Vale run as NOT RUN and fall back to full LLM judgment.
|
||||
|
||||
This scope expands per ADR-0013: one cherry-picked low-noise `write-good`/`alex` rule landed in `styles/Kyberforge`, `Kyberforge.SentenceOpenerThereIs` (22 held-out hits, both in-corpus hits clean rewrites, zero suppressions). A second, `Kyberforge.VagueQualifier`, was cherry-picked and then deleted: 2 hits across the 41 skill/agent files, one marginal and one an unfixable false positive (`caveman/SKILL.md` quotes `of course` as an example of filler — a mention, not a use) that forced the repo's only Vale suppression comments. Also new is a sibling pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforcing agentskills.io's `SKILL.md` ceiling as two blocking gates: `MAX_LINES=500` and `MAX_WORDS=2770` (a word-count proxy for the 5,000-token limit, calibrated to the densest prose measured in this repo — 1.81 tokens per word — so even a worst-case `SKILL.md` at the ceiling stays under 5,000 tokens). Both are inclusive, and `skill-audit/scripts/validate.sh` checks the same pair on the same terms, so a `SKILL.md` can no longer pass its own audit yet be blocked by the commit hook. Scoped to `^plugins/[^/]+/skills/[^/]+/SKILL\.md$` only, same as `vale-audit-prefilter-skill`, so it never lints `docs/research/examples/` reference skills. It's also exposed in the root-level `.pre-commit-hooks.yaml` as `kyberforge-skill-size-check` — it has no external asset dependency, so it needed no relocation, only exposure to external consumers. File scope (`SKILL.md` + agent files) and enforcement model (rules land directly in `styles/Kyberforge`, blocking immediately, no trial tier) stay unchanged; governance.md/CONTROLS.md were evaluated and excluded as rule sources (nothing prose-pattern-matchable to mine). House convention: banned phrasing that must be mentioned rather than used goes in backticks or a fenced code block — Vale skips code spans and fences, so no suppression is needed; inline `<!-- vale Rule = NO -->` (HTML-comment form; the MDX `{/* */}` form does not work in plain Markdown) is the fallback only where backticking is impossible.
|
||||
|
||||
### LESSONS.md
|
||||
Long-loop feedback log for patterns observed across sessions. Three or more entries on the same pattern graduate to the relevant standing file (e.g. a coding convention, a governance rule). Updated by the session-handoff skill or directly by the human. Lives at the repo root.
|
||||
|
||||
28
LESSONS.md
28
LESSONS.md
@@ -137,31 +137,3 @@ After a PR merge (with Gitea's default auto-delete-branch behavior), `git branch
|
||||
## 2026-05-18 — Planning meta-commentary does not belong in deployed artifacts
|
||||
|
||||
During write-skill refactor, an "open thread" note (about a deferred research step) was written directly into the SKILL.md Process section. The user caught it. The rule it violated: a deployed artifact (SKILL.md, a runtime file loaded by agents) must not contain planning meta-commentary — deferred items, open threads, and implementation notes belong in the issue file, which is the planning artifact. The skill body should contain only content relevant to runtime execution. If a decision is deferred, record it in the issue and leave no trace in the skill. The distinction: issue = planning record; skill = executable instruction.
|
||||
|
||||
## 2026-08-08 — A clean linter result can mean "nothing was checked"
|
||||
|
||||
Three separate times in one PR (#85), a check reported success because it had silently not run. (1) Vale's `text.frontmatter.description` scope stops matching once the value is a multi-line YAML block scalar — the style most skills here use — so a repo-wide sweep returned 0 alerts across 49 files and was read as a clean repo. (2) Five of six rules were `level: warning`, but Vale's exit code keys on `error` alone and pre-commit hides output from passing hooks, so those rules were invisible and blocked nothing for two review rounds while the ADR described them as "enforcing immediately." (3) `.vale.ini`'s globs matched no file outside `plugins/`, so Vale printed "0 files" and exited 0, which both audit skills read as "no findings" and used to skip their own judgment passes. Each time the green result was worse than no check at all, because it was cited as positive evidence of cleanliness. Fix: for any new check, prove it fails before trusting that it passes — run it against a deliberately-bad fixture, confirm the failure, then run the real corpus. Where a check can scan zero inputs, assert on the input count, not just the exit code. **[graduated → core/instructions/testing.md]** (4th instance below, kept for audit trail).
|
||||
|
||||
**5th instance (2026-08-09, PR #85 round 6):** `tests/test-vale-hooks-consumer.sh` asserted `grep -c "VagueWording" >= 2` across the *combined* output of both shipped Vale hooks, and the SKILL.md fixture alone raised two alerts — so one working hook satisfied the threshold and the agent hook could be disabled entirely (glob retargeted to match nothing) while the suite still reported `3 passed` under the message "both hooks flatten and flag". The `Skipped` guard did not catch it: the hook still *matched* the file, Vale simply linted nothing, reported `0 errors in 1 file`, and exited 0, which pre-commit renders as `Passed`. The general shape: **an assertion that aggregates over N subjects proves nothing about any individual subject** — a total is satisfiable by a proper subset. Fix: attribute each signal to its source before asserting (alerts are now filed by path, with a distinct trigger token per fixture so one hook's alert cannot be credited to another), and assert per subject. Corollary technique, now standing practice for any check whose failure mode is silence: run the mutation sweep in *reverse* as well — neuter each assertion in turn and confirm exactly one test case fails. Applied to `check-vale-style-sync.sh` it exposed two assertions bound to no failing case at all, one of them masked by a stronger check that ran first.
|
||||
|
||||
**4th instance (2026-08-09, ADR-0014):** splitting the single root `.vale.ini` into two skill-scoped copies (skill-audit: `SKILL.md` only; agent-audit: agent files only) meant a single retargeted pre-commit hook pointed at agent-audit's copy alone would have silently scanned 0 `SKILL.md` files and exited 0 — caught only because the full corpus was dry-run against both the old and new config and the outputs diffed before the old config was deleted, not because any test asserted on file counts. Standing practice going forward: when a Vale (or any linter) config that serves multiple file-glob scopes is split or moved, dry-run the full corpus through both the old and new config and diff the outputs before removing the superseded source — a hook silently scanning 0 files looks identical to a clean pass.
|
||||
|
||||
## 2026-08-08 — One signal, two consumers, no named distinction
|
||||
|
||||
Vale's output fed two consumers with different contracts: the audit skills read severity *strings* to grade a report (`error`→FAIL, `warning`→SUGGESTION), while the pre-commit hook read the process *exit code* to allow or block a commit. Severities were tuned for the first consumer; the second silently inherited whatever exit code that produced, which was always 0. CONTEXT.md described both as a single mechanism under one heading, which is precisely why the divergence went unnoticed — there was no vocabulary in which "the gate" and "the prefilter" were different things that could disagree. Fix: when one output feeds two consumers, name them separately in the domain language and state each contract explicitly. If they cannot be given independent contracts, collapse them into one — which is what happened here: every rule became `level: error`, so the gate and the audit now share a single verdict with nothing to keep in sync.
|
||||
|
||||
## 2026-08-08 — Measure a rule's false-positive rate at the severity you will ship it at
|
||||
|
||||
`Kyberforge.VagueQualifier` was cherry-picked from `write-good` after being trialled as "low-noise against this repo's corpus" — but the trial ran at `level: warning`, where a false positive costs nothing because nobody ever sees it. Shipped at `error`, the same false positive costs a blocked commit and a permanent suppression comment. Re-measured at the severity it actually shipped at, the rule scored one marginal true positive and one unfixable false positive across 41 files (`caveman/SKILL.md` *quotes* filler words as its subject matter — a mention, not a use), and was deleted. Fix: trial conditions must match shipping conditions. A noise measurement taken where false positives are free does not transfer to a context where they are expensive, and "low-noise" is not a property of a rule alone — it is a property of the rule at a severity.
|
||||
|
||||
## 2026-08-09 — Exercising a config's "local" mode proves nothing about the mode that ships
|
||||
|
||||
The root `.pre-commit-hooks.yaml` shipped Vale hooks whose `entry:` carried a `--config <repo-relative-path>` argument. pre-commit prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`), so every later argument resolves against the *consuming* repo's root: each external consumer hard-failed with `E100 [--config] Runtime error ... does not exist`, and two of the three hooks ADR-0014 promised were unusable. The defect survived three review rounds of PR #85 and a green `pre-commit run --all-files` every time, because this repo consumes the same hooks through `repo: local`, where the clone prefix, the cwd, and the repo root are one directory — the byte-identical `entry:` string worked locally for a reason that exists only locally. Nothing under `tests/` exercised the manifest as a hook repo at all. The sharp part: the local run was not weaker evidence of the same thing, it was evidence of a different thing, and the two were indistinguishable by reading either file. Fix: when a config has a local mode whose resolution semantics differ from the shipped mode, test the shipped mode against a real consumer — `tests/test-vale-hooks-consumer.sh` stands up a `file://` clone of this repo and runs the hooks from it — and then delete the divergence rather than living with it: `vale-wrap.sh` now self-locates its config from `${BASH_SOURCE[0]}`, and the local and shipped `entry:` lines are identical, so the local run no longer exercises a path no consumer takes.
|
||||
|
||||
## 2026-08-09 — Deleting a token from a shared artifact breaks whatever parses it, silently
|
||||
|
||||
Dropping the `--config` argument from `.pre-commit-hooks.yaml` was the right fix, but `scripts/check-release-needed.sh` derived its release-relevant path list by scanning those same `entry:` lines for `--config` and taking the target's `dirname` — that parse was the only thing giving the bundled `.vale.ini` and its sibling `styles/` tree release coverage. With the token gone the loop simply never fired: no error, no failing test, no warning, just a path list that shrank from six entries to four and lost both `assets/vale/` trees. Consequence: a change to a Vale *rule* could land on `main` without demanding a release tag, leaving external consumers pinned to an old `rev:` with stale rules — the exact drift the gate exists to prevent. It surfaced only because the agent making the change reported it as a suspected side effect of its own edit, and was confirmed by diffing the derived path list before and after. Fix: before removing a token from an artifact more than one script reads, grep for everything that *parses* the artifact, not just everything that consumes its documented purpose. The smell to watch for is a loop that builds a list, where an empty or short list is indistinguishable from a correct one — assert on the expected members, so a derivation whose input vanished fails loudly instead of quietly covering less.
|
||||
|
||||
## 2026-08-09 — A documented impossibility is a claim, not a constraint
|
||||
|
||||
`vale-wrap.sh` flattens multi-line YAML `description:` scalars so Vale's `text.frontmatter.description` scope keeps matching. Its last-resort branch rewrote ASCII `'` to U+2019, justified at the emission site and in review as "the single combination no YAML scalar can carry verbatim" — an accepted-by-design residual, documented and test-covered, which is exactly why nobody retested it. The claim was false: a `|-` literal block with one indented content line carries `'`, `"`, `\` and `: ` verbatim, keeps the scope alive, and the wrapper's own header docstring already said literal blocks were unaffected. The cost of the unexamined claim was a silent underlint on 12 of 54 in-scope files — any rule whose token contained an apostrophe simply never fired, and the covering test (case 20) pinned only "the scope stays alive", so it passed either way. Fix: when a residual is accepted because something is "impossible", write down the specific claim in a falsifiable form and test *that*, not the workaround built on top of it. The tell here was that the residual and its justification were documented in the same breath by the same author — documentation records a belief, and a belief adjacent to a workaround is the one most worth attacking. Related: an assertion written to cover an accepted residual tends to assert the residual's *presence* rather than the behaviour it costs; case 20b asserted the scope survived flattening, never that a rule matching the rewritten characters still fired.
|
||||
|
||||
@@ -23,4 +23,3 @@ Read these files on demand:
|
||||
|
||||
- **Coding conventions** (`~/.claude/core/instructions/coding.md`) — when writing, editing, or reviewing code
|
||||
- **Testing conventions** (`~/.claude/core/instructions/testing.md`) — when writing or running tests
|
||||
- **Subagent orchestration** (`~/.claude/core/instructions/subagent-orchestration.md`) — when spawning or coordinating subagents/forks
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Subagent orchestration
|
||||
|
||||
- A fork stops when its assigned task is done. It inherits the coordinator's full context, including any shared TaskList — that visibility is not license to keep pulling further items after its assigned task is reported complete; doing so races the coordinator's own orchestration and can duplicate or conflict with separately-delegated work.
|
||||
- Don't hand a fork a TaskList containing governance-gated actions (push, publish, merge) unless prepared for it to act on those without a fresh confirmation round. A fork acting on its own initiative is not party to any pending human confirmation the coordinator is mid-flow on.
|
||||
- `TaskGet`/`TaskUpdate`/`TaskList` only work for forks. Fresh (non-fork) subagents cannot discover or call these tools — when delegating to a fresh subagent, the coordinator owns all task-list bookkeeping itself.
|
||||
- `Agent(isolation: "worktree")` may fork from `main`, not the branch the coordinator was on. Verify and self-correct (`git merge --ff-only <target-branch>` or reset onto `origin/<target-branch>`) before editing. When removing such a worktree afterward, use `git worktree remove --force --force <path>` if the repo has submodules (double `-f` required), then `git branch -d` both the feature branch and the auto-created `worktree-agent-<id>` isolation branch.
|
||||
@@ -4,4 +4,3 @@
|
||||
- Automate everything automatable. Manual testing only for nuanced UI/UX or agent interaction behaviour requiring human judgment.
|
||||
- Test observable end-state, not implementation internals. Tests must survive refactoring.
|
||||
- No test is better than a wrong test. A passing mock that masks a real failure is actively harmful.
|
||||
- A clean result can mean nothing ran. Before trusting a new check, prove it fails against a deliberately-bad fixture, then run it against the real target. Where a check can scan zero inputs, assert on the input count, not just the exit code — a zero-file run and a real clean pass look identical otherwise.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# AGENTS.md tooling lives in `core`, split into three skills
|
||||
|
||||
`kyberforge` is scoped to meta-tooling for building and maintaining the holocron marketplace itself (skills, agents, plugins, marketplace entries) — not to generic capabilities for an arbitrary target repo. Authoring and reviewing a target repo's `AGENTS.md` file is repo-agnostic documentation tooling, closer in kind to `bin:write-docs` or `bin:init` than to `skill-author`/`plugin-author`. Research for this topic was initially placed under `plugins/kyberforge/docs/research/docs/agentsmd/` but has moved to `plugins/core/docs/research/docs/agentsmd/` to keep the provenance chain consistent with the plugin the resulting skills live in.
|
||||
|
||||
## Decision
|
||||
|
||||
Three skills in the `core` plugin (`core`'s first active skills):
|
||||
|
||||
- **`agentsmd-author`** — creates/updates a target repo's `AGENTS.md`, including nested monorepo placement (nearest-file-wins). Closes out by invoking `agentsmd-audit` inline, mirroring the `skill-author`/`skill-audit` pattern. When it detects an existing provider-specific file (`CLAUDE.md`, etc.) with content that duplicates what AGENTS.md should own, it calls `provider-adapter-author` via skill composition.
|
||||
- **`agentsmd-audit`** — a single combined pass checking three mandatory baselines against `AGENTS.md` only: secrets/credentials (governance.md hard prohibition), structural completeness (common-sections checklist from the agents.md spec), and accuracy/drift (do referenced commands/paths resolve against the repo). Never inspects provider adapter files.
|
||||
- **`provider-adapter-author`** — detects and converts a provider-specific instruction file into a thin adapter that imports `AGENTS.md` (mirroring this repo's own two-tier `CLAUDE.md` pattern). Self-validates via its own bundled deterministic script (`scripts/validate-adapter.sh`) rather than a separate paired audit skill, since the check (import present, no duplicated headings, size threshold) is mechanical.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `core`'s plugin.json/README will list real skills for the first time.
|
||||
- `plugins/kyberforge/docs/research/docs/agentsmd/` moves to `plugins/core/docs/research/docs/agentsmd/` before authoring begins.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Vale audit prefilter expands into a plugin-content harness, scoped to prose-pattern rules only
|
||||
|
||||
Issue #84 wired Vale as a deterministic prefilter for `skill-audit`/`agent-audit`, scoped to
|
||||
exactly four pattern-matchable checks (imperative description opener, vague capability wording,
|
||||
generic reference-pointer padding, Copilot's dead `Use proactively` phrasing), documented only in
|
||||
CONTEXT.md's "Vale audit prefilter" section — never its own ADR — and explicitly excluding body
|
||||
discipline, near-miss exclusion strength, and control calibration as non-goals. This ADR records a
|
||||
deferred PR #85 review item to broaden that coverage, retroactively captures #84's own rationale
|
||||
(since it was never recorded as a decision in its own right), and layers the expansion on top
|
||||
without reversing or weakening the original four rules.
|
||||
|
||||
**File scope stays the same.** `SKILL.md` plus agent files (`**/agents/*.md`,
|
||||
`**/*.agent.md`) only — matching the existing prefilter's globs. Skill-level
|
||||
`README.md` files and `plugin.json` manifests are not added: README.md files are navigational, not
|
||||
spec-governed content, and `plugin.json` is JSON, not prose Vale can meaningfully lint.
|
||||
|
||||
**Rule categories are prose-pattern-matchable only.** Structural, schema, and security concerns
|
||||
stay out of this Vale-based harness because this repo already has dedicated tools for them:
|
||||
`skill-frontmatter` (required frontmatter fields), `validate-plugins`/`validate-marketplace`
|
||||
(`claude plugin validate --strict`, schema), and `gitleaks`/`detect-private-key` (secrets).
|
||||
Duplicating those concerns as Vale rules would fight tools that already own them better.
|
||||
|
||||
**Governance docs are excluded as a rule source.** `docs/research/governance_principles/CONTROLS.md`
|
||||
and `governance.md` were investigated and found to contribute nothing minable: CONTROLS.md is
|
||||
org/CI-infrastructure controls (secret scanning, dependency/license scanning, agent permission
|
||||
scoping, audit logging, human approval gates, periodic reviews) — none of it is a prose pattern
|
||||
expressible as a Vale rule against SKILL.md/agent-file text, and what it does cover is either
|
||||
already handled elsewhere (gitleaks) or genuinely out of scope for a plugin-content prose harness
|
||||
(dependency/license scanning is a code-dependency concern, not skill authoring).
|
||||
|
||||
**Spec-derived custom rules stay mostly as-is.** Re-reading agentskills.io's
|
||||
`optimizing-descriptions.md` and `skill-authoring.md`, plus `claude-code-plugins/agent-definition.md`
|
||||
and `github-copilot-plugins/agent-definition.md`, found that the existing four Kyberforge rules
|
||||
already cover the pattern-matchable surface those specs describe. The remaining spec guidance —
|
||||
calibrating control vs. giving freedom, avoiding menus of options, coherent skill scope, moderate
|
||||
detail level — is semantic judgment, already `skill-audit`'s job via LLM review, not new lintable
|
||||
rules. One confirmation surfaced: Claude Code's `Use proactively` phrasing is meaningful for `.md`
|
||||
agent files (it triggers auto-invocation), unlike Copilot's `.agent.md` files where it's dead
|
||||
phrasing — so `KyberforgeCopilot/ProactivePhrase`'s existing `.agent.md`-only scope is correct and
|
||||
must not be extended to `.md` files.
|
||||
|
||||
**`write-good`/`alex` are trialed, not adopted wholesale.** These built-in/third-party Vale
|
||||
packages are tuned for general blog-style prose (passive voice, weasel words, wordy phrases) and
|
||||
are expected to be noisy against this repo's terse, imperative instruction-file corpus. Only
|
||||
individual rules proven low-noise against the existing corpus get cherry-picked into
|
||||
`styles/Kyberforge`; the packages are never referenced wholesale in `BasedOnStyles`.
|
||||
|
||||
**A new non-Vale check closes a real gap.** `skill-authoring.md` states `SKILL.md` should stay
|
||||
under 500 lines / 5,000 tokens — currently unenforced anywhere in this repo. This is a whole-file
|
||||
length ceiling, not a text pattern, so it isn't a Vale rule — it becomes a new deterministic script
|
||||
and pre-commit hook, sibling to the existing `skill-frontmatter` hook.
|
||||
|
||||
**Rules land directly in `styles/Kyberforge`, enforcing immediately.** No trial/report-only tier
|
||||
is introduced (see Considered Options). "Enforcing immediately" holds only because every rule in
|
||||
both styles is `level: error`: Vale's exit code keys on `error`-level alerts alone, so a
|
||||
`warning`- or `suggestion`-level rule prints an alert and still exits 0, and pre-commit suppresses
|
||||
output from hooks that pass — such a rule is invisible and blocks nothing. Every Vale alert is
|
||||
therefore a FAIL, in the audit skills and in the blocking pre-commit hook alike, with no ignorable
|
||||
tier; that matches every other gate in this repo (shellcheck, the test suite,
|
||||
conventional-pre-commit). The implementation pass finalizes the cherry-picked
|
||||
`write-good`/`alex` rules and any new spec-derived rule wording, runs the full set against the
|
||||
existing SKILL.md/agent-file corpus, fixes any resulting violations across that corpus, and lands
|
||||
the rule changes and the corpus fixes as one atomic commit — the same enforcement model as the
|
||||
original four rules, never a partial or opt-in state.
|
||||
|
||||
## Considered options
|
||||
|
||||
**Phased rollout via a separate trial style + config (rejected).** A `styles/KyberforgeTrial/`
|
||||
directory plus a parallel `.vale.trial.ini` (mirroring the root config's globs but with
|
||||
`BasedOnStyles = Kyberforge, KyberforgeTrial`) would let new rules be swept report-only via
|
||||
`lint-runner`/`vale-run` before promotion into the enforcing `styles/Kyberforge` + root
|
||||
`.vale.ini`. This was considered because `BasedOnStyles = Kyberforge` activates every rule file
|
||||
under that directory automatically — there's no partial/opt-in application within a style, so a
|
||||
rule dropped straight into `styles/Kyberforge` goes live in the blocking pre-commit hook
|
||||
immediately. Rejected in favor of finalizing rules directly and fixing violations via subagent
|
||||
before committing: simpler, no new trial-config machinery to build or maintain — at the cost of no
|
||||
standing report-only tier for future candidate rules. Note that the first implementation shipped
|
||||
graded severities (`error`/`warning`/`suggestion`) and thereby recreated the rejected option by
|
||||
accident: the five non-`error` rules never affected an exit code and never surfaced output through
|
||||
a passing pre-commit hook, so they were a report-only tier that reported to nobody. Flattening
|
||||
every rule to `level: error` is what actually implements this decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `styles/Kyberforge/` gained one new rule file, cherry-picked from `write-good`/`alex` as
|
||||
low-noise against this repo's corpus: `SentenceOpenerThereIs.yml` (22 hits across 273 held-out
|
||||
markdown files; both in-corpus hits were clean rewrites, needing no suppression).
|
||||
- A second candidate, `VagueQualifier.yml`, was cherry-picked and then dropped. Against the 41
|
||||
skill/agent files it hit twice: one marginal real finding (`prototype/SKILL.md`, "very different"
|
||||
→ "fundamentally different") and one false positive (`caveman/SKILL.md`, which *quotes* `of
|
||||
course` as an example of filler — a mention, not a use) that no rewrite could clear, forcing the
|
||||
repo's only Vale suppression comments. Of its 15 held-out hits, 9 were in `docs/research/examples/`
|
||||
(out-of-scope upstream material) and the remaining 6 were the word "very" in two idioms in a
|
||||
single research doc, each already adjacent to the hard number carrying the fact. One marginal
|
||||
catch does not pay for a permanent suppression, so the rule is deleted and this ADR's
|
||||
"cherry-picked rules" is one rule, not two.
|
||||
- A new pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforces the
|
||||
500-line/5,000-token `SKILL.md` ceiling, sibling to `skill-frontmatter`. Both halves of that
|
||||
ceiling are blocking gates, not just the line count: `MAX_LINES=500`, and `MAX_WORDS=2770` as a
|
||||
word-count proxy for the 5,000-token limit (calibrated to the densest prose this repo measured,
|
||||
1.81 tokens per word, so a worst-case `SKILL.md` at the ceiling still lands under 5,000 tokens —
|
||||
`wc -w` is not BPE tokenization). Either one exceeded fails the hook. Both are
|
||||
inclusive: a file at exactly 500 lines or exactly 2,770 words passes, and only one past a ceiling
|
||||
fails. `skill-audit/scripts/validate.sh` enforces the same pair on the same inclusive terms, so
|
||||
the audit and the commit hook cannot disagree about whether a given `SKILL.md` is over size.
|
||||
- `styles/KyberforgeTrial/` and `.vale.trial.ini` were deliberately not created — noted here so a
|
||||
future reader doesn't wonder if a trial tier was forgotten.
|
||||
- The styles-portability question — whether `styles/` and `.vale.ini` should move into
|
||||
`plugins/lint/` so the prefilter also works for repos that install `kyberforge@holocron` as an
|
||||
external plugin, rather than living at this repo's root — was deliberately deferred, not fixed,
|
||||
in this pass. This repo-root placement remains intentional: this ADR's "File scope stays the
|
||||
same" framing is specific to Kyberforge's own authoring conventions in this repo, not a generic
|
||||
`lint`-plugin feature. Portability is a known limitation, tracked for a separate future session,
|
||||
not silently forgotten.
|
||||
|
||||
**What this ADR's implementation pass did:** synced and trialed `write-good`/`alex` against the
|
||||
existing SKILL.md/agent-file corpus, cherry-picked the one low-noise rule above into
|
||||
`styles/Kyberforge`, wrote `scripts/skill-size-check.sh` and its pre-commit hook, fixed the
|
||||
resulting corpus violations, and landed the rule changes and corpus fixes as one atomic commit —
|
||||
matching the enforcement model described above (no partial or opt-in state), with every rule at
|
||||
`level: error` so that model is real rather than nominal.
|
||||
@@ -1,188 +0,0 @@
|
||||
# Kyberforge's Vale prefilter ships from the plugin, with `.pre-commit-hooks.yaml` for external git-hook/CI enforcement
|
||||
|
||||
**Resolves:** ADR-0013's deferred "styles-portability" consequence — `.vale.ini`/`styles/` moving
|
||||
out of the repo root was deliberately deferred there, not fixed. ADR-0013's other content
|
||||
(rule scope, `level: error` model, `SentenceOpenerThereIs`/`VagueQualifier` trial outcomes) is
|
||||
unaffected and remains in force.
|
||||
|
||||
`skill-audit`/`agent-audit`'s Step 1 called
|
||||
`"$(git rev-parse --show-toplevel)/scripts/vale-wrap.sh" --config "$(git rev-parse --show-toplevel)/.vale.ini"`
|
||||
— which resolves to whichever repo the skill happens to be running in. Inside `ai-development`
|
||||
that's this repo; in any external repo that installs `kyberforge@holocron` as a plugin, it's that
|
||||
repo's own root, which has no `.vale.ini` or `vale-wrap.sh`. The prefilter silently fell back to
|
||||
full LLM judgment every time outside this repo — the exact gap ADR-0013 named and deferred.
|
||||
|
||||
## Decision
|
||||
|
||||
**Runtime (a live Claude Code session):** the Vale config, styles, and wrapper script move into
|
||||
the plugin itself, following the no-cross-skill-path rule already established in
|
||||
`skill-author/references/deployment-modes.md` (a plugin's cache-install only copies each skill's
|
||||
own files; there is no plugin-level shared directory). `agent-audit` needs both `Kyberforge` and
|
||||
`KyberforgeCopilot` (it lints `.agent.md` files), so `plugins/kyberforge/skills/agent-audit/assets/vale/`
|
||||
is the canonical, superset copy. `skill-audit` needs a second, smaller copy
|
||||
(`plugins/kyberforge/skills/skill-audit/assets/vale/`, `Kyberforge` only) since it cannot
|
||||
reference agent-audit's copy across the skill boundary. Both skills' Step 1 now resolve
|
||||
`scripts/vale-wrap.sh`/`assets/vale/.vale.ini` relative to their own directory, the same way
|
||||
`scripts/validate.sh <skill-dir>` already does — no new resolution mechanism, just applying the
|
||||
existing one consistently.
|
||||
|
||||
**git hooks / CI outside a Claude Code session** have no plugin cache and no
|
||||
`${CLAUDE_PLUGIN_ROOT}` — a CI runner in particular is guaranteed not to have one. The mechanism
|
||||
that works there for any consumer, with or without Claude Code installed, is pre-commit's own
|
||||
hook-repo protocol: this repo now ships a root-level `.pre-commit-hooks.yaml` exposing
|
||||
`kyberforge-vale-audit-skill`, `kyberforge-vale-audit-agent`, and `kyberforge-skill-size-check`.
|
||||
Any external repo adds `repo: <this-repo-url>, rev: <tag>` to its own `.pre-commit-config.yaml`
|
||||
and gets all three, fully decoupled from Claude Code. CI is the identical `pre-commit run
|
||||
--all-files` call, so the same manifest covers "possibly CI" from the original ask.
|
||||
|
||||
**This repo's own dev-time gate** consumes the same plugin-bundled copies instead of a third
|
||||
root-level copy — per explicit instruction, this repo should be set up like any other consumer
|
||||
would be, not dogfood a special root-only path. The existing `repo: local` hook is retargeted
|
||||
(not removed): `entry:` now points at `plugins/kyberforge/skills/{skill-audit,agent-audit}/scripts/vale-wrap.sh`.
|
||||
`repo: local` is kept rather than switching to a pinned self-reference
|
||||
(`repo: <own-url>, rev: <tag>`) — a pinned self-reference would lint working-tree edits against
|
||||
the *last tagged release*, not the change actually being made, which is wrong for the repo that
|
||||
*is* the source of the hook. This mirrors standard practice among hook-author repos (pre-commit's
|
||||
own `pre-commit-hooks`, `shellcheck-py`): `repo: local` for self-consumption, `.pre-commit-hooks.yaml`
|
||||
for everyone else, same underlying files and commands either way.
|
||||
|
||||
**One hook per file-scope, not one combined hook.** The old root `.vale.ini` had both the
|
||||
`[**/SKILL.md]` and `[**/agents/*.md]`/`[**/*.agent.md]` glob sections in a single file, so one
|
||||
pre-commit hook covered both. Splitting the config into two skill-scoped copies means a single
|
||||
hook entry pointed at only one copy would silently 0-file-skip the other file type. Both the
|
||||
local `.pre-commit-config.yaml` hooks and the external-facing `.pre-commit-hooks.yaml` therefore
|
||||
define separate `-skill`/`-agent` hook IDs, each with a `files:` regex matching exactly what its
|
||||
target copy's glob covers. (Confirmed empirically before deleting the root files: retargeting a
|
||||
single hook at agent-audit's copy silently scanned 0 SKILL.md files.)
|
||||
|
||||
**The hook `entry:` is the wrapper alone; the wrapper self-locates its config.** pre-commit
|
||||
prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`);
|
||||
every later argument is handed to the process untouched and so resolves against the *consuming*
|
||||
repo's root. A `--config plugins/kyberforge/skills/…/assets/vale/.vale.ini` in
|
||||
`.pre-commit-hooks.yaml` therefore named a path no consumer has, and every external run died with
|
||||
`E100 [--config] Runtime error`. The external-consumer contract this ADR exists to establish
|
||||
cannot be expressed as a `--config` argument at all — the config path has to be derived inside
|
||||
the process, from the script's own location. `vale-wrap.sh` accordingly defaults to its sibling
|
||||
`assets/vale/.vale.ini`, resolved from `${BASH_SOURCE[0]}`, whenever no `--config` is supplied;
|
||||
an explicit `--config` from any other caller still wins and still resolves against the caller's
|
||||
cwd, so both audit skills' Step 1 (`--config assets/vale/.vale.ini`) is unaffected. Both
|
||||
manifests now carry the identical argument-free `entry:`. Keeping them identical is part of the
|
||||
decision: the local `repo: local` hook resolved its `--config` correctly only because the
|
||||
consuming repo *was* this repo, and that one difference is why three review rounds exercised a
|
||||
code path no external consumer ever takes.
|
||||
|
||||
**Vale's `StylesPath` resolves relative to the `.vale.ini` file's own location**, confirmed
|
||||
against `docs.vale.sh/keys/stylespath` — so a config path into the plugin finds that ini's
|
||||
sibling `styles/` regardless of the caller's cwd, whether it arrives as an explicit `--config` or
|
||||
as the wrapper's self-located default. No extra path-juggling is needed beyond `vale-wrap.sh`'s
|
||||
cwd-relative `--config`/path-argument handling and that fallback.
|
||||
|
||||
**A sync-check catches drift between the two copies.** `scripts/check-vale-style-sync.sh` diffs
|
||||
`scripts/vale-wrap.sh` and `assets/vale/styles/Kyberforge/` between skill-audit and agent-audit
|
||||
(not `.vale.ini` — those legitimately differ, scoped to different glob sections), wired at
|
||||
`pre-push` alongside `check-manifests`. `.vale.ini` itself isn't diffed since divergence there is
|
||||
by design.
|
||||
|
||||
**External `.pre-commit-hooks.yaml` consumers pin `rev:` to a tag, not a commit SHA.** This repo
|
||||
had no tags before this change; going forward, a `vX.Y.Z` tag is cut whenever hook-relevant files
|
||||
change, matching how every other `repo:` entry in this repo's own `.pre-commit-config.yaml`
|
||||
already pins (`v2.4.0`, `v8.21.2`, ...).
|
||||
|
||||
## Considered options
|
||||
|
||||
**Keep a third root-level copy, dogfooded specially (rejected).** Simpler in that this repo's own
|
||||
hook wouldn't need retargeting at all. Rejected on explicit instruction: this repo should consume
|
||||
the same portability path an external repo would, not carve out a special root-only case that
|
||||
never gets exercised the way external consumers exercise it.
|
||||
|
||||
**Publish styles as a hosted Vale package via `Packages = <zip-url>` (deferred, not rejected).**
|
||||
Vale supports fetching a style from a direct `.zip` URL via `vale sync`, fully decoupled from
|
||||
Claude Code and from pre-commit's hook-repo protocol — usable by any repo, even ones that never
|
||||
install `kyberforge` at all. This is a larger, separate investment (a release/versioning pipeline
|
||||
for the package itself) not required to satisfy the current ask; noted here so a future reader
|
||||
doesn't wonder if it was overlooked.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Root `.vale.ini`, `styles/`, `scripts/vale-wrap.sh` are deleted. Two copies remain:
|
||||
`plugins/kyberforge/skills/agent-audit/assets/vale/` (canonical, superset) and
|
||||
`plugins/kyberforge/skills/skill-audit/assets/vale/` (subset, `Kyberforge` only).
|
||||
- `plugins/kyberforge`'s `plugin.json` and `.claude-plugin/plugin.json` both patch-bump for every
|
||||
shipped content change (per ADR-0006's version-parity invariant): `1.2.5` for the relocation
|
||||
itself, `1.2.6` for the self-locating `vale-wrap.sh` that followed.
|
||||
- **`.pre-commit-hooks.yaml` entries are a bare script path and nothing else — a constraint, not a
|
||||
house style, and it binds every future hook here, not just the Vale two.** Since pre-commit
|
||||
rewrites only `entry[0]` into the hook-repo clone, no argument token in any entry can reference
|
||||
a file this repo ships: a relative path resolves against the *consuming* repo and hard-fails,
|
||||
and the absolute path is unknowable at author time. A hook that needs one of its own bundled
|
||||
files must have the script self-locate it from `$0`/`${BASH_SOURCE[0]}`, exactly as
|
||||
`vale-wrap.sh` now does for `.vale.ini`. Anything else rediscovers this as another `E100`.
|
||||
`.pre-commit-config.yaml` stays byte-identical to the shipped manifest on those `entry:` lines
|
||||
so the local gate keeps exercising the same resolution path a consumer does.
|
||||
- `tests/test-vale-wrap.sh` now exercises skill-audit's copy specifically — its fixtures are all
|
||||
`SKILL.md`-shaped, and only skill-audit's `.vale.ini` has the matching glob section.
|
||||
- The first `vX.Y.Z` tag is cut once this change and its tests pass, giving external
|
||||
`.pre-commit-hooks.yaml` consumers something to pin.
|
||||
- **Cutting the tag is not left to memory.** `scripts/check-release-needed.sh`, wired at
|
||||
`pre-push`, hard-fails — but only when `PRE_COMMIT_REMOTE_BRANCH` (set by pre-commit's
|
||||
`hook-impl` for pre-push hooks) is `refs/heads/main` — if any path `.pre-commit-hooks.yaml`
|
||||
exposes changed since the last tag reachable from `HEAD`. It is a silent no-op on every other
|
||||
branch: hard-failing on feature-branch pushes mid-review would force a premature tag on a
|
||||
commit that might not survive a squash-merge, the exact problem `repo: local` (above) already
|
||||
avoids for this repo's own dev-time gate. A tag not existing at all is also a hard fail on
|
||||
`main`, covering the very first release. This is deterministic tooling, not a standing
|
||||
instruction to remember — consistent with `check-manifests.sh`/`check-vale-style-sync.sh`
|
||||
already using the same pre-push, main-agnostic-elsewhere pattern.
|
||||
- **Known limitation, not yet closed:** `check-release-needed.sh` only fires when a human runs
|
||||
`git push` locally with pre-commit's hooks installed — `PRE_COMMIT_REMOTE_BRANCH` is set by
|
||||
pre-commit's client-side `hook-impl` script parsing `git push`'s stdin protocol. A PR merged
|
||||
through Gitea's merge button (server-side, no local push) or a CI runner invoking
|
||||
`pre-commit run --hook-stage pre-push` directly never sets it, so the gate silently doesn't run
|
||||
in either path. This repo has no CI workflow yet (`has_actions` is enabled but unused), so
|
||||
closing this gap needs a server-side job re-running the same script on merge to `main` — deferred
|
||||
as a separate piece of infrastructure, not fixed here. `RELEASE_PATHS` is derived from
|
||||
`.pre-commit-hooks.yaml`'s own `entry:` lines rather than hand-maintained, so at least the set of
|
||||
paths it checks can't drift from the manifest on its own.
|
||||
- **Dropping `--config` moved the release gate's path derivation too.** `check-release-needed.sh`
|
||||
used to reach each hook's bundled assets through the `dirname` of its `--config` target. With
|
||||
no `--config` token left, that loop went dead and silently dropped both `assets/vale/` trees
|
||||
from release coverage — a Vale *rule* change could then land on `main` without demanding a tag,
|
||||
leaving consumers pinned to an old `rev:` running stale rules while the gate stayed green. The
|
||||
script now derives the bundle's `assets/` tree from `tokens[0]` instead (double-`dirname`,
|
||||
guarded on the candidate existing and on not resolving to `.`), which is the only derivation
|
||||
compatible with the argument-free `entry:` contract above.
|
||||
- **Accepted residual in the release gate (closed — see the update below):** deleting a hook's
|
||||
*entire* `assets/` tree is not flagged — the derived candidate path stops existing, so the guard
|
||||
drops it before it reaches the pathspec. Deleting individual files inside a surviving tree is
|
||||
flagged, and tested.
|
||||
|
||||
**Update (commit `14c2c91`):** the accepted residual above no longer holds and is recorded here
|
||||
only as the state at the time this ADR was written. `check-release-needed.sh` no longer derives
|
||||
release-relevant paths from the worktree alone. It runs `collect_release_paths` twice — once over
|
||||
the worktree's `.pre-commit-hooks.yaml`, once over the manifest read back from `$LAST_TAG` via
|
||||
`git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST"` — and unions the two path sets, so a path the tag
|
||||
exposed stays in the pathspec even after the worktree's `-d` guard drops it. Wholesale deletion of
|
||||
a hook's bundled `assets/` tree is therefore flagged, and `tests/test-check-release-needed.sh`
|
||||
(case 12) asserts exit 1 for exactly that case. The union does not over-fire: any manifest edit
|
||||
that makes the two disagree already touches `$HOOKS_MANIFEST`, itself a release-relevant path. An
|
||||
unreadable tagged tree (shallow clone, truncated fetch) fails closed rather than silently degrading
|
||||
to worktree-only derivation; a manifest simply absent at the tag — legitimate, it was added since —
|
||||
does not.
|
||||
|
||||
**Update — the flattener rewrites no characters.** This ADR never recorded it as a decision, but
|
||||
`vale-wrap.sh`'s flattener carried a lossy last-resort branch: when a description needed quoting
|
||||
*and* held an ASCII apostrophe *and* held a double quote or backslash, it substituted U+2019 (`’`)
|
||||
for every `'` before writing the scratch copy, on the stated rationale that no verbatim YAML scalar
|
||||
could carry that combination. The rationale was wrong. A `|-` literal block with a single indented
|
||||
content line carries `'`, `"`, `\` and `: ` byte for byte — a block scalar's body has no escape
|
||||
syntax at all — and vale's `text.frontmatter.description` scope still matches and fires rules on it
|
||||
(verified against vale 3.15.2; it is the same property that makes the `|` blocks in the wrapper's
|
||||
header safe to leave unflattened). The branch fired on 12 of the 54 in-scope files in this repo,
|
||||
silently disabling every rule whose token contains an apostrophe on each of them. The flattener now
|
||||
emits that literal block instead, so its output is verbatim in all four forms and no Vale rule can
|
||||
be silently disabled by the prefilter. The `|-` form is two physical lines where the three inline
|
||||
forms are one, so the blank-line pad that preserves later line numbers drops by one — reachable
|
||||
only when the original span is already two or more lines, so the pad count stays non-negative.
|
||||
`tests/test-vale-wrap.sh` case 20 asserts an apostrophe-bearing token actually fires on a flattened
|
||||
description in all three apostrophe-carrying branches, and case 20b pins the pad arithmetic against
|
||||
a body line's true line number.
|
||||
@@ -8,5 +8,5 @@
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"name": "bin",
|
||||
"version": "1.1.1"
|
||||
"version": "1.0.5"
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"skills": [
|
||||
"skills/"
|
||||
],
|
||||
"version": "1.1.1"
|
||||
"version": "1.0.5"
|
||||
}
|
||||
|
||||
38
plugins/bin/skills/gitea/README.md
Normal file
38
plugins/bin/skills/gitea/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# gitea
|
||||
|
||||
Dispatch skill for managing a Gitea repo — issues, PRs, milestones, labels, and branches — from within Claude Code.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Skill definition — dispatch table, gotchas, execution steps |
|
||||
| `references/token-access.md` | Token scope inventory — what works vs. what needs additional scopes |
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/gitea # status: open issues + open PRs
|
||||
/gitea issue # create issue from conversation context
|
||||
/gitea issue <N> # get issue details
|
||||
/gitea issue close <N> # close issue
|
||||
/gitea issue comment <N> # add comment from conversation context
|
||||
/gitea label <N> Kind/Bug # apply labels by name (resolves IDs automatically)
|
||||
/gitea milestone # list milestones
|
||||
/gitea milestone create <title> # create milestone
|
||||
/gitea pr # create PR from current branch → main
|
||||
/gitea pr <N> # get PR status and diff summary
|
||||
/gitea pr merge <N> # squash-merge PR, delete branch
|
||||
/gitea branch # list branches
|
||||
/gitea branch create <name> # create branch from current branch
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Gitea MCP server configured in `~/.claude.json` with `write:issue` and `write:repository` token scopes
|
||||
- `git remote origin` pointing to the Gitea instance (used to derive owner/repo at runtime)
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
In scope: issues, milestones, labels, PRs, branches, status.
|
||||
Out of scope: releases, CI/Actions, wiki, file operations, notifications, packages, time tracking.
|
||||
152
plugins/bin/skills/gitea/SKILL.md
Normal file
152
plugins/bin/skills/gitea/SKILL.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: gitea
|
||||
description: >
|
||||
Use when the user wants to interact with Gitea — create or update issues,
|
||||
open or merge pull requests, manage labels and milestones, list branches,
|
||||
or check repo status. Always use this skill to interact with the GiteaMCP, never use GiteaMCP directly.
|
||||
Triggers on: "create an issue", "open a PR", "what's
|
||||
open", "label this issue", "create a milestone", "merge the PR", "list
|
||||
branches", "close this issue" — even when the user doesn't say "Gitea"
|
||||
explicitly. Owner and repo are derived automatically from the git remote;
|
||||
no config required. Do not use for releases, CI/Actions, wiki, file
|
||||
operations, notifications, or package management — those are out of scope.
|
||||
compatibility: Requires Gitea MCP server configured in ~/.claude.json with write:issue and write:repository token scopes. Requires git remote "origin" pointing to the Gitea instance.
|
||||
allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__issue_write mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__list_branches mcp__gitea__create_branch
|
||||
metadata:
|
||||
category: integration
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Label writes take IDs, reads return names.** `issue_write` (add_labels, replace_labels) requires `labels: [3, 7]` (numeric IDs). Issue and PR responses return `labels: ["bug", "enhancement"]` (name strings). These are never interchangeable. Always call `label_read method: "list_repo_labels"` first and resolve names → IDs before any label write.
|
||||
- **Issues and PRs share a number space.** `#5` might be an issue or a PR — there is only one counter per repo. `list_issues` returns issues only — it has no `type` parameter. Use `list_pull_requests` separately for PRs. Check `is_pull` on a single-item `issue_read` response to determine whether a number refers to an issue or PR.
|
||||
- **Milestone write takes ID, not title.** `issue_write` takes `milestone: <numeric id>`. The title is not accepted. In `issue_read` responses the milestone is `{id, title}`, but in `pull_request_read` responses it's a bare title string — you cannot recover the ID from a PR response. Call `milestone_read method: "list"` and match by title if you need the ID from a PR context.
|
||||
- **`get_me` is unavailable** with the current token (`write:issue, write:repository` only — `read:user` is missing). Owner and repo must always be derived from the git remote, never from `get_me` or `list_my_repos`.
|
||||
- **Merging a PR does not itself close linked issues — but a commit message landing on the default branch can.** Gitea has no GitHub-style "merge triggers close" event. It does, however, parse closing keywords (`Fixes #N`, `Closes #N`) in commit messages pushed to the default branch. A regular (non-squash) merge preserves each original commit message, so if any of those commits says `Fixes #N`, the issue auto-closes at merge time — confirmed empirically (PR #64 auto-closed #63 this way, before any explicit `issue_write` call was made). This skill's own `pr merge` dispatch defaults to `merge_style: "squash"` (Step 3), which rewrites history into one commit — whether the keyword survives depends on what message that squash commit ends up with, so squash-merged PRs are the case most likely to still need an explicit close. Always call `issue_read method: "get"` to check current state before manually closing after a merge — closing an already-closed issue is a harmless no-op, but don't assume a manual close is always needed.
|
||||
- **Pagination is manual.** List tools return one page at a time — no auto-pagination. When building complete datasets (e.g. all labels for name→ID mapping), iterate `page: 1, 2, ...` until result count < `per_page`.
|
||||
- **`pull_request_read method: "get"` returns `review_scomments`, not `review_comments`.** This is a source-level typo in gitea-mcp v1.3.0. Do not access `review_comments` — it will always be undefined. Use `review_scomments`.
|
||||
- **Cross-repo fork PRs require `head` as `"fork-owner:branch-name"`.** A bare branch name causes Gitea to search the base repo and return 422. The `pr create` dispatch assumes same-repo PRs (bare branch name). For fork-based PRs, pass `head` explicitly in the `owner:branch` format.
|
||||
- **`draft: true` on PR create prepends `WIP:` to the title.** There is no first-class draft field — Gitea implements draft PRs via title prefix. To un-draft, call `pull_request_write method: "update"` and pass the title without the `WIP:` prefix. This differs from GitHub's draft PR model.
|
||||
- **HTTP 404 may mean 403.** Gitea hides permission errors as not-found to avoid leaking resource existence. If a tool call returns 404 unexpectedly, check `references/token-access.md` before assuming the resource does not exist.
|
||||
|
||||
## Step 1 — Resolve owner and repo
|
||||
|
||||
Before any tool call, extract `owner` and `repo` from the git remote:
|
||||
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||
|
||||
## Step 2 — Dispatch
|
||||
|
||||
Route on the first argument:
|
||||
|
||||
| Invocation | Action |
|
||||
|---|---|
|
||||
| `/gitea` (no args) | **Status** — list open issues + open PRs |
|
||||
| `/gitea issue` | Create issue from conversation context |
|
||||
| `/gitea issue <N>` | Get issue details |
|
||||
| `/gitea issue close <N>` | Close issue |
|
||||
| `/gitea issue comment <N>` | Add comment from conversation context |
|
||||
| `/gitea label <N> <names...>` | Apply named labels to issue/PR |
|
||||
| `/gitea milestone` | List milestones |
|
||||
| `/gitea milestone create <title>` | Create milestone |
|
||||
| `/gitea pr` | Create PR from current branch → main |
|
||||
| `/gitea pr <N>` | Get PR status and diff summary |
|
||||
| `/gitea pr merge <N>` | Merge PR (squash, delete branch) |
|
||||
| `/gitea branch` | List branches |
|
||||
| `/gitea branch create <name>` | Create branch from current branch |
|
||||
|
||||
## Step 3 — Execute
|
||||
|
||||
### Status (default)
|
||||
|
||||
Call `list_issues state: "open"` and `list_pull_requests state: "open"` in parallel. `list_issues` does not accept a `type` parameter — it returns issues only. `list_pull_requests` returns PRs. Report as two sections.
|
||||
|
||||
### issue (create)
|
||||
|
||||
Extract title and body from conversation context. Use the most recent task, bug description, grill output, or explicit statement. If no body text is available from context, fall back to empty string. Fire immediately — no confirmation step.
|
||||
|
||||
**Label inference (do this before the create call):**
|
||||
1. Call `label_read method: "list_repo_labels"` to get all available labels with their IDs.
|
||||
2. From conversation context, infer which labels apply:
|
||||
- Issue type → `Kind/*`: bug reports → `Kind/Bug`; new capabilities → `Kind/Feature`; improvements → `Kind/Enhancement`; docs → `Kind/Documentation`; security → `Kind/Security`
|
||||
- Urgency signals → `Priority/*`: "blocking", "critical", "urgent" → `Priority/Critical`; "soon", "high priority" → `Priority/High`; default → `Priority/Medium`
|
||||
- Explicit blocking → `Status/Blocked`
|
||||
3. Resolve inferred label names to IDs from the label list. **Labels require numeric IDs — never pass name strings to `issue_write`.** If no labels can be confidently inferred, omit the `labels` parameter entirely rather than guessing.
|
||||
|
||||
Set `ref` to the current branch name (`git branch --show-current`) if a branch is already checked out for this work.
|
||||
|
||||
Call `issue_write method: "create" title: <extracted> body: <extracted or ""> labels: [<inferred IDs or omit>] ref: <current-branch-if-applicable>`.
|
||||
|
||||
### issue <N>
|
||||
|
||||
Call `issue_read method: "get" issue_number: <N>`. If the response includes `is_pull: true`, the number refers to a PR — report it as such and offer `pr <N>` for a full PR summary.
|
||||
|
||||
### issue close <N>
|
||||
|
||||
Call `issue_write method: "update" issue_number: <N> state: "closed"`. There is no `method: "close"` — using a non-existent method will error.
|
||||
|
||||
### issue comment <N>
|
||||
|
||||
Extract the comment body from conversation context (same sourcing as issue create). Call `issue_write method: "add_comment" issue_number: <N> body: <extracted>`.
|
||||
|
||||
### label <N> <names...>
|
||||
|
||||
1. Call `label_read method: "list_repo_labels"` — paginate until complete if > 30 labels.
|
||||
2. Match each provided name (case-insensitive) against the label list → collect IDs.
|
||||
3. Call `issue_write method: "add_labels" issue_number: <N> labels: [<matched IDs>]`.
|
||||
4. Report applied labels and warn on any names that did not match, listing available labels.
|
||||
|
||||
Do not fail the operation because of unmatched names — apply what matches.
|
||||
|
||||
### milestone
|
||||
|
||||
Call `milestone_read method: "list"`. Report each milestone as: id, title, state (open/closed), open issue count, closed issue count.
|
||||
|
||||
### pr (create)
|
||||
|
||||
1. `git branch --show-current` → head branch.
|
||||
2. Title: extract from conversation context; fall back to the last commit message (`git log -1 --pretty=%s`).
|
||||
3. Body: extract from conversation; fall back to empty.
|
||||
4. Call `pull_request_write method: "create" head: <branch> base: "main" title: <derived in step 2> body: <derived in step 3>`.
|
||||
|
||||
Note: this dispatch assumes a same-repo PR (bare branch name for `head`). For cross-repo fork PRs, `head` must be `"fork-owner:branch-name"` — see Gotchas.
|
||||
|
||||
### pr <N>
|
||||
|
||||
Call `pull_request_read method: "get"` and `pull_request_read method: "get_status"` in parallel (both take `pull_number: <N>`). Report: title, state, draft/merged flag, head → base, labels, CI status from get_status. Note: `milestone` in PR responses is a bare title string, not an object — you cannot extract a milestone ID from it.
|
||||
|
||||
### pr merge <N>
|
||||
|
||||
First call `pull_request_read method: "get_status" pull_number: <N>`. If CI status is failing, report it and warn the user — but do not block the merge unless they say to stop.
|
||||
|
||||
Then call `pull_request_write method: "merge" pull_number: <N> merge_style: "squash" delete_branch: true`. To use a different merge style, the user must specify it explicitly.
|
||||
|
||||
Squashing rewrites history into one commit — whether a linked issue's closing keyword survives depends on what message that squash commit ends up with. After merging, call `issue_read method: "get"` on any issue referenced by the PR to check whether it auto-closed before deciding whether to close it explicitly (see the auto-close gotcha above).
|
||||
|
||||
### milestone create <title>
|
||||
|
||||
Call `milestone_write method: "create" title: <title>`. Report the created milestone ID — it will be needed for assigning issues.
|
||||
|
||||
### branch
|
||||
|
||||
Call `list_branches`. Report each branch as: name, protected (bool).
|
||||
|
||||
### branch create <name>
|
||||
|
||||
Get the current local branch: `git branch --show-current`. Call `create_branch branch: <name> old_branch: <current-branch>`. This forks the new branch from where you are, not from the repo's default branch. If the user specifies a different base explicitly, use that instead.
|
||||
|
||||
## Step 4 — Report
|
||||
|
||||
For reads: display results as a compact table or numbered list — include number, title, labels, and milestone for issues/PRs.
|
||||
|
||||
For writes: confirm what was created/updated with the Gitea issue/PR number and URL if returned.
|
||||
|
||||
For errors: surface the HTTP code and message. 404 from some endpoints may actually mean insufficient token scope (Gitea hides 403 as 404 to avoid leaking resource existence).
|
||||
|
||||
If label resolution fails partially, always report which names were applied and which were skipped.
|
||||
|
||||
If token scope issues are suspected, read `references/token-access.md` for the full scope inventory.
|
||||
@@ -14,7 +14,7 @@ Identify which question is being answered — from the user's prompt, the surrou
|
||||
- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper.
|
||||
- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar.
|
||||
|
||||
The two branches produce fundamentally different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype.
|
||||
The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype.
|
||||
|
||||
## Rules that apply to both
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"name": "core",
|
||||
"version": "1.1.0"
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# core
|
||||
|
||||
Cross-cutting utility skills for everyday AI-assisted coding — triage, diagnosis, architecture review, and session navigation.
|
||||
|
||||
## Install
|
||||
|
||||
**Claude Code:**
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add <owner>/<repo>
|
||||
claude plugin install core@<marketplace-name>
|
||||
```
|
||||
|
||||
**GitHub Copilot CLI:**
|
||||
|
||||
```bash
|
||||
copilot plugin marketplace add <owner>/<repo>
|
||||
copilot plugin install core
|
||||
```
|
||||
|
||||
**Local (development):**
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude --plugin-dir ./plugins/core
|
||||
|
||||
# GitHub Copilot CLI
|
||||
copilot plugin install ./plugins/core
|
||||
```
|
||||
|
||||
## Contents
|
||||
|
||||
| Component | Path | Description |
|
||||
|---|---|---|
|
||||
| Skills | `skills/` | Slash commands available after install |
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Description |
|
||||
|---|---|
|
||||
| `agentsmd-author` | Create or update a repo's AGENTS.md by exploring real build/test/lint conventions; supports nested monorepo placement and hands off to agentsmd-audit and provider-adapter-author |
|
||||
| `agentsmd-audit` | Audit a repo's AGENTS.md for embedded secrets, structural completeness, and drift; produces a findings report |
|
||||
| `provider-adapter-author` | Convert a provider-specific instruction file (CLAUDE.md, `.cursor/rules/*.mdc`, copilot-instructions.md, etc.) into a thin adapter that defers to AGENTS.md |
|
||||
|
||||
## Author
|
||||
|
||||
Defame1297
|
||||
@@ -15,8 +15,5 @@
|
||||
"license": "MIT",
|
||||
"mcpServers": ".mcp.json",
|
||||
"name": "core",
|
||||
"skills": [
|
||||
"skills/"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# agentsmd-audit
|
||||
|
||||
Audit a target repo's AGENTS.md file(s) for embedded secrets, structural completeness, and drift.
|
||||
|
||||
## What it does
|
||||
|
||||
Runs a single combined pass across every AGENTS.md file in a repo (root and any nested monorepo files): flags embedded secrets/credentials, checks structure against the agents.md common-sections checklist, and resolves referenced commands/paths against the actual repo to catch stale documentation. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix. Never inspects provider-specific adapter files (CLAUDE.md, etc.) and never writes or fixes anything.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/agentsmd-audit
|
||||
```
|
||||
|
||||
Provide the path to the repo root to audit when invoking.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `scripts/validate-secrets.sh` | Scans AGENTS.md files for embedded secrets, API keys, tokens, connection strings |
|
||||
| `scripts/validate-structure.sh` | Checks for empty/placeholder content, common-sections checklist, nested-vs-root duplication |
|
||||
| `scripts/validate-drift.sh` | Resolves referenced npm/make commands and file paths against the repo |
|
||||
| `references/sources.md` | Provenance record — sources that informed this skill and which files each contributed to |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-secrets.bats` | Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `tests/validate-structure.bats` | Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `tests/validate-drift.bats` | Bats test suite for `scripts/validate-drift.sh` |
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: agentsmd-audit
|
||||
description: >
|
||||
Use when the user wants to review a repo's AGENTS.md file, says "audit this
|
||||
AGENTS.md", "check my AGENTS.md", "is this AGENTS.md any good", or wants to
|
||||
know if AGENTS.md is safe to commit — even if they don't use the word
|
||||
"audit". Also invoke proactively after agentsmd-author creates or updates
|
||||
AGENTS.md, or after a hand-edit made outside agentsmd-author. Audits a
|
||||
target repo's AGENTS.md file(s) — root and any nested monorepo files — for
|
||||
embedded secrets/credentials, structural completeness against the
|
||||
agents.md common-sections checklist, and drift (referenced commands or
|
||||
paths that no longer resolve against the repo). Produces a compact
|
||||
findings report (findings only, no PASS noise) with Why and Fix per
|
||||
finding. Do not use to audit CLAUDE.md, .cursor/rules, or other
|
||||
provider-specific adapter files — that's provider-adapter-author's
|
||||
self-contained concern. Do not use to fix or write AGENTS.md content — use
|
||||
agentsmd-author instead.
|
||||
allowed-tools: Bash Read
|
||||
metadata:
|
||||
category: docs
|
||||
source_keys:
|
||||
- agents-md-official
|
||||
- context7-websites-agents-md
|
||||
- context7-agentsmd-agents-md
|
||||
- governance-secrets-hard-prohibition
|
||||
version: "0.1.1"
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Always run all three checks — this skill does a single combined pass, not staged/gated passes. Don't skip structure or drift checks just because a secrets FAIL was found.
|
||||
- Never inspect or mention provider-specific adapter files (`CLAUDE.md`, `.cursor/rules/*.mdc`, `copilot-instructions.md`, etc.) — that's out of scope. If one exists and duplicates AGENTS.md content, that's `provider-adapter-author`'s concern, not this skill's.
|
||||
- A missing common section (e.g. no "Security" heading) is informational, not a failure — not every repo needs every section from the checklist. Only flag a FAIL when the file is empty, entirely unfilled placeholder text, or contains a real embedded secret/stale reference.
|
||||
- Gather findings internally; don't narrate PASS/FAIL per check as you go — surface them only in the final report.
|
||||
|
||||
## Step 1 — Run the validators
|
||||
|
||||
```bash
|
||||
bash scripts/validate-secrets.sh <repo-root>
|
||||
bash scripts/validate-structure.sh <repo-root>
|
||||
bash scripts/validate-drift.sh <repo-root>
|
||||
```
|
||||
|
||||
Each script walks the repo for every `AGENTS.md` file (root and nested, excluding `.git`, `node_modules`, `vendor`, and similar) and prints `FAIL`/`INFO`/`SUGGESTION` lines with `Why`/`Fix` (or `Note`) per finding. A nonzero exit means at least one FAIL was found in that dimension. If a script cannot execute (`python3` unavailable, Bash denied), fall back to manual review: scan for real-looking credentials, check common sections are present, and spot-check a few referenced commands/paths by hand.
|
||||
|
||||
## Step 2 — Report
|
||||
|
||||
Open with a coverage line:
|
||||
|
||||
```text
|
||||
Checked: secrets · structure · drift
|
||||
```
|
||||
|
||||
Then output only findings that were found, in this order within a repo: `### Secrets`, `### Structure`, `### Drift`. Omit a dimension heading entirely if it produced nothing — its absence confirms it passed. Report each finding verbatim as emitted by the scripts (they already carry file:line, Why/Fix or Note).
|
||||
|
||||
Close with a result block:
|
||||
|
||||
```text
|
||||
## Result
|
||||
|
||||
PASS
|
||||
PASS · P info
|
||||
PASS (N suggestions) · P info
|
||||
FAIL (N fails)
|
||||
FAIL (N fails) · P info
|
||||
```
|
||||
|
||||
INFO and SUGGESTION findings are observational — they never flip PASS to FAIL. Do not fix anything — this skill reports and proposes only. Point the user to `agentsmd-author` to apply fixes.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Sources
|
||||
|
||||
## agents-md-official
|
||||
|
||||
- **URL:** https://agents.md/
|
||||
- **Description:** Official agents.md website — format spec, common-sections checklist, precedence rules (nearest-file-wins, no merge across files), monorepo nesting patterns
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-websites-agents-md
|
||||
|
||||
- **URL:** context7:/websites/agents_md
|
||||
- **Description:** Context7 index of the official agents.md website — overview, governance, cross-tool compatibility, configuration examples
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-agentsmd-agents-md
|
||||
|
||||
- **URL:** context7:/agentsmd/agents.md
|
||||
- **Description:** Context7 index of the agentsmd/agents.md repository — format spec, nested monorepo patterns, file structure examples
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## governance-secrets-hard-prohibition
|
||||
|
||||
- **URL:** (org convention — not a plugin research corpus entry)
|
||||
- **Description:** Hard prohibition on placing secrets, API keys, tokens, or credentials in code, config, prompts, or any output. Grounds the secrets/credentials check in `scripts/validate-secrets.sh` and Step 1 of SKILL.md — AGENTS.md is committed content, so an embedded real secret is a hard-prohibition violation, not a style nit.
|
||||
- **Research doc:** core/instructions/governance.md (org convention file, not a plugin research corpus entry; content is inlined here since plugins must be self-contained and this file may not exist wherever the plugin is installed)
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
@@ -1,11 +0,0 @@
|
||||
# scripts/
|
||||
|
||||
Deterministic validators this skill shells out to instead of relying on LLM judgment for mechanical checks.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `validate-secrets.sh` | Scans every AGENTS.md file (root + nested) for embedded secrets, API keys, tokens, and connection strings |
|
||||
| `validate-structure.sh` | Checks for empty/placeholder content, the common-sections checklist, and nested-vs-root duplication |
|
||||
| `validate-drift.sh` | Resolves referenced npm/make commands and file paths against the actual repo state |
|
||||
|
||||
All three take a single `<repo-root>` argument, print `FAIL`/`INFO`/`SUGGESTION` findings to stdout, and exit non-zero only on FAIL.
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate-drift.sh <repo-root>
|
||||
|
||||
Check every AGENTS.md file in a repo (root and nested) for drift: package
|
||||
manager scripts and file paths referenced in the text that no longer exist
|
||||
in the repo. Catches the failure mode that matters most in practice — an
|
||||
agent running a documented command that was renamed or deleted.
|
||||
|
||||
Arguments:
|
||||
repo-root Path to the repository root to scan.
|
||||
|
||||
Exit codes:
|
||||
0 No FAIL findings (INFO may still be printed, e.g. no package.json found)
|
||||
1 One or more FAIL findings
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Error: repo-root is required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -u - "$1" <<'PYTHON'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
|
||||
repo_root = os.path.abspath(sys.argv[1])
|
||||
if not os.path.isdir(repo_root):
|
||||
print(f"Error: '{repo_root}' is not a directory.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
EXCLUDE_DIRS = {".git", "node_modules", "vendor", ".venv", "venv", "dist", "build"}
|
||||
|
||||
def find_agents_md(root):
|
||||
results = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
||||
for fname in filenames:
|
||||
if fname == "AGENTS.md":
|
||||
results.append(os.path.join(dirpath, fname))
|
||||
return sorted(results)
|
||||
|
||||
def load_package_scripts(root):
|
||||
pkg_path = os.path.join(root, "package.json")
|
||||
if not os.path.isfile(pkg_path):
|
||||
return None
|
||||
try:
|
||||
with open(pkg_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return set(data.get("scripts", {}).keys())
|
||||
|
||||
def load_make_targets(root):
|
||||
make_path = os.path.join(root, "Makefile")
|
||||
if not os.path.isfile(make_path):
|
||||
return None
|
||||
with open(make_path, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
return set(re.findall(r'(?m)^([a-zA-Z0-9_-]+)\s*:(?!=)', content))
|
||||
|
||||
NPM_RUN_RE = re.compile(r'\b(?:npm|pnpm|yarn)\s+run\s+([a-zA-Z0-9:_-]+)')
|
||||
MAKE_RE = re.compile(r'\bmake\s+([a-zA-Z0-9_-]+)')
|
||||
|
||||
# Backticked relative file paths, e.g. `scripts/bootstrap.sh`, `src/index.ts`.
|
||||
# Requires a path separator and file extension to avoid matching bare commands/words.
|
||||
PATH_RE = re.compile(r'`([A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)+\.[A-Za-z0-9]+)`')
|
||||
|
||||
has_fail = False
|
||||
|
||||
package_scripts = load_package_scripts(repo_root)
|
||||
make_targets = load_make_targets(repo_root)
|
||||
|
||||
for fpath in find_agents_md(repo_root):
|
||||
rel = os.path.relpath(fpath, repo_root)
|
||||
with open(fpath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
|
||||
for m in NPM_RUN_RE.finditer(content):
|
||||
script_name = m.group(1)
|
||||
if package_scripts is None:
|
||||
print(f"INFO Cannot verify referenced script '{script_name}' — {rel}")
|
||||
print(f" Note: AGENTS.md references an npm/pnpm/yarn script, but no package.json was found at the repo root to check it against.")
|
||||
print()
|
||||
elif script_name not in package_scripts:
|
||||
has_fail = True
|
||||
print(f"FAIL Referenced script '{script_name}' not found in package.json — {rel}")
|
||||
print(f" Why: AGENTS.md tells agents to run '{script_name}', but package.json has no matching \"scripts\" entry — the command will fail.")
|
||||
print(f" Fix: Update AGENTS.md to reference an existing script, or add '{script_name}' to package.json's scripts.")
|
||||
print()
|
||||
|
||||
for m in MAKE_RE.finditer(content):
|
||||
target_name = m.group(1)
|
||||
if make_targets is None:
|
||||
print(f"INFO Cannot verify referenced make target '{target_name}' — {rel}")
|
||||
print(f" Note: AGENTS.md references a make target, but no Makefile was found at the repo root to check it against.")
|
||||
print()
|
||||
elif target_name not in make_targets:
|
||||
has_fail = True
|
||||
print(f"FAIL Referenced make target '{target_name}' not found in Makefile — {rel}")
|
||||
print(f" Why: AGENTS.md tells agents to run 'make {target_name}', but the Makefile has no matching target — the command will fail.")
|
||||
print(f" Fix: Update AGENTS.md to reference an existing target, or add '{target_name}' to the Makefile.")
|
||||
print()
|
||||
|
||||
file_dir = os.path.dirname(fpath)
|
||||
for m in PATH_RE.finditer(content):
|
||||
candidate = m.group(1)
|
||||
resolved = (
|
||||
os.path.isfile(os.path.join(repo_root, candidate))
|
||||
or os.path.isfile(os.path.join(file_dir, candidate))
|
||||
or os.path.isdir(os.path.join(repo_root, candidate))
|
||||
or os.path.isdir(os.path.join(file_dir, candidate))
|
||||
)
|
||||
if not resolved:
|
||||
has_fail = True
|
||||
print(f"FAIL Referenced path '{candidate}' does not exist — {rel}")
|
||||
print(f" Why: AGENTS.md points agents to '{candidate}', but it isn't present in the repo (checked relative to repo root and to the AGENTS.md's own directory).")
|
||||
print(f" Fix: Update AGENTS.md to reference the correct path, or restore/create '{candidate}'.")
|
||||
print()
|
||||
|
||||
if has_fail:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PYTHON
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate-secrets.sh <repo-root>
|
||||
|
||||
Scan every AGENTS.md file in a repo (root and nested) for embedded secrets,
|
||||
API keys, tokens, or connection strings. AGENTS.md is committed content —
|
||||
real credentials in it are a hard-prohibition violation, not a style nit.
|
||||
Placeholders (<your-key>, \$ENV_VAR, YOUR_TOKEN_HERE, example.com, etc.) are
|
||||
not flagged.
|
||||
|
||||
Arguments:
|
||||
repo-root Path to the repository root to scan.
|
||||
|
||||
Exit codes:
|
||||
0 No findings
|
||||
1 One or more FAIL findings
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Error: repo-root is required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -u - "$1" <<'PYTHON'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
repo_root = os.path.abspath(sys.argv[1])
|
||||
if not os.path.isdir(repo_root):
|
||||
print(f"Error: '{repo_root}' is not a directory.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
EXCLUDE_DIRS = {".git", "node_modules", "vendor", ".venv", "venv", "dist", "build"}
|
||||
|
||||
def find_agents_md(root):
|
||||
results = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
||||
for fname in filenames:
|
||||
if fname == "AGENTS.md":
|
||||
results.append(os.path.join(dirpath, fname))
|
||||
return sorted(results)
|
||||
|
||||
PLACEHOLDER_RE = re.compile(
|
||||
r'(?i)(your[_-]|my[_-]|example|xxx+|placeholder|changeme|<[^>]+>|\$\{|\$[A-Z_][A-Z0-9_]*|\.\.\.|redacted)'
|
||||
)
|
||||
|
||||
PATTERNS = [
|
||||
("AWS access key ID", re.compile(r'AKIA[0-9A-Z]{16}')),
|
||||
("Private key block", re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----')),
|
||||
("GitHub token", re.compile(r'gh[pousr]_[A-Za-z0-9]{36,}')),
|
||||
("Slack token", re.compile(r'xox[baprs]-[A-Za-z0-9-]{10,}')),
|
||||
("GitLab token", re.compile(r'glpat-[A-Za-z0-9_-]{20,}')),
|
||||
("Generic API-style secret token", re.compile(r'\bsk-[A-Za-z0-9]{20,}\b')),
|
||||
(
|
||||
"Credential-bearing connection string",
|
||||
re.compile(r'[a-zA-Z][a-zA-Z0-9+.-]*://[^:@/\s]+:[^@/\s]+@[^\s\'"]+'),
|
||||
),
|
||||
(
|
||||
"Assigned secret/password/token literal",
|
||||
re.compile(
|
||||
r'(?i)\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\b'
|
||||
r'\s*[:=]\s*[\'"]?([A-Za-z0-9+/_.\-]{12,})[\'"]?'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
findings = []
|
||||
|
||||
def emit_fail(desc, fpath, lineno, why, fix):
|
||||
findings.append((desc, fpath, lineno, why, fix))
|
||||
|
||||
for fpath in find_agents_md(repo_root):
|
||||
rel = os.path.relpath(fpath, repo_root)
|
||||
with open(fpath, encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if PLACEHOLDER_RE.search(line):
|
||||
continue
|
||||
for label, pattern in PATTERNS:
|
||||
m = pattern.search(line)
|
||||
if not m:
|
||||
continue
|
||||
# Re-check placeholder allowlist against just the matched value, in case
|
||||
# the placeholder marker sits outside the regex's own match span.
|
||||
value = m.group(0)
|
||||
if PLACEHOLDER_RE.search(value):
|
||||
continue
|
||||
emit_fail(
|
||||
f"Possible {label}",
|
||||
f"{rel}:{i}",
|
||||
i,
|
||||
"AGENTS.md is committed content; this line matches a real-looking credential pattern rather than a placeholder.",
|
||||
"Remove the embedded credential and replace it with an environment variable reference or placeholder (e.g. $API_KEY, <your-token>).",
|
||||
)
|
||||
break
|
||||
|
||||
if not findings:
|
||||
sys.exit(0)
|
||||
|
||||
for desc, fpath, _lineno, why, fix in findings:
|
||||
print(f"FAIL {desc} — {fpath}")
|
||||
print(f" Why: {why}")
|
||||
print(f" Fix: {fix}")
|
||||
print()
|
||||
|
||||
sys.exit(1)
|
||||
PYTHON
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate-structure.sh <repo-root>
|
||||
|
||||
Check every AGENTS.md file in a repo (root and nested) for structural
|
||||
completeness against the agents.md spec's common-sections checklist
|
||||
(setup/build, code style, testing, security, commit/PR conventions).
|
||||
Missing individual sections are informational (not every repo needs every
|
||||
section) — only an empty or entirely unfilled file is a hard failure.
|
||||
|
||||
Arguments:
|
||||
repo-root Path to the repository root to scan.
|
||||
|
||||
Exit codes:
|
||||
0 No FAIL findings (INFO/SUGGESTION may still be printed)
|
||||
1 One or more FAIL findings
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Error: repo-root is required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -u - "$1" <<'PYTHON'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r'(?i)FILL IN:|TODO:\s*write|lorem ipsum')
|
||||
|
||||
COMMON_SECTIONS = [
|
||||
("setup/build commands", re.compile(r'(?im)^#{1,3}\s*(setup|install|build|getting started)')),
|
||||
("code style", re.compile(r'(?im)^#{1,3}\s*(code style|style guide|conventions)')),
|
||||
("testing instructions", re.compile(r'(?im)^#{1,3}\s*(test|testing)')),
|
||||
("security considerations", re.compile(r'(?im)^#{1,3}\s*security')),
|
||||
("commit/PR conventions", re.compile(r'(?im)^#{1,3}\s*(commit|pr|pull request)')),
|
||||
]
|
||||
|
||||
repo_root = os.path.abspath(sys.argv[1])
|
||||
if not os.path.isdir(repo_root):
|
||||
print(f"Error: '{repo_root}' is not a directory.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
EXCLUDE_DIRS = {".git", "node_modules", "vendor", ".venv", "venv", "dist", "build"}
|
||||
|
||||
def find_agents_md(root):
|
||||
results = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
||||
for fname in filenames:
|
||||
if fname == "AGENTS.md":
|
||||
results.append(os.path.join(dirpath, fname))
|
||||
return sorted(results)
|
||||
|
||||
has_fail = False
|
||||
file_contents = {} # rel path -> content, for the duplication pass below
|
||||
|
||||
for fpath in find_agents_md(repo_root):
|
||||
rel = os.path.relpath(fpath, repo_root)
|
||||
with open(fpath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
file_contents[rel] = content
|
||||
|
||||
if not content.strip():
|
||||
has_fail = True
|
||||
print(f"FAIL AGENTS.md is empty — {rel}")
|
||||
print(" Why: An empty file provides no instructions and gives agents nothing to act on.")
|
||||
print(" Fix: Add at least a project overview and setup/test commands, per the agents.md common-sections checklist.")
|
||||
print()
|
||||
continue
|
||||
|
||||
if PLACEHOLDER_RE.search(content):
|
||||
has_fail = True
|
||||
print(f"FAIL Unfilled placeholder content — {rel}")
|
||||
print(" Why: A 'FILL IN:' or template stub left in place means the file has no repo-specific instructions yet.")
|
||||
print(" Fix: Replace the placeholder with real, repo-specific content.")
|
||||
print()
|
||||
continue
|
||||
|
||||
for label, pattern in COMMON_SECTIONS:
|
||||
if not pattern.search(content):
|
||||
print(f"INFO No {label} section — {rel}")
|
||||
print(f" Note: The agents.md common-sections checklist includes {label}; not every repo needs every section, but confirm this omission is deliberate.")
|
||||
print()
|
||||
|
||||
# --- Nested-vs-root duplication check ---
|
||||
root_content = file_contents.get("AGENTS.md")
|
||||
if root_content:
|
||||
root_lines = {ln.strip() for ln in root_content.splitlines() if ln.strip()}
|
||||
for rel, content in file_contents.items():
|
||||
if rel == "AGENTS.md":
|
||||
continue
|
||||
nested_lines = [ln.strip() for ln in content.splitlines() if ln.strip()]
|
||||
if not nested_lines:
|
||||
continue
|
||||
overlap = sum(1 for ln in nested_lines if ln in root_lines)
|
||||
ratio = overlap / len(nested_lines)
|
||||
if ratio >= 0.7:
|
||||
print(f"SUGGESTION Nested AGENTS.md largely duplicates the root file — {rel}")
|
||||
print(f" Why: {ratio:.0%} of this file's content lines already appear in the root AGENTS.md; per the spec's nearest-file-wins precedence, nested files don't inherit from the root, but they also shouldn't just restate it.")
|
||||
print(f" Fix: Trim {rel} down to only what's specific to this package/directory.")
|
||||
print()
|
||||
|
||||
if has_fail:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PYTHON
|
||||
@@ -1,30 +0,0 @@
|
||||
# tests/
|
||||
|
||||
Test files for scripts bundled with this skill.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tests require [bats-support](https://github.com/bats-core/bats-support) and
|
||||
[bats-assert](https://github.com/bats-core/bats-assert). The test files load
|
||||
helpers from the repo root's `tests/test_helper/`.
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bats-core/bats-support tests/test_helper/bats-support
|
||||
git clone https://github.com/bats-core/bats-assert tests/test_helper/bats-assert
|
||||
```
|
||||
|
||||
Run all tests for this skill (from the repo root):
|
||||
|
||||
```bash
|
||||
bats plugins/core/skills/agentsmd-audit/tests/
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `validate-secrets.bats` | Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `validate-structure.bats` | Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `validate-drift.bats` | Bats test suite for `scripts/validate-drift.sh` |
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../" && pwd)"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-support/load"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
|
||||
|
||||
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate-drift.sh"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
@test "fails when AGENTS.md references a stale npm script" {
|
||||
cat > "$TMPDIR/package.json" <<'EOF'
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Testing
|
||||
- Run `pnpm run e2e` before committing.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "e2e"
|
||||
}
|
||||
|
||||
@test "passes when the referenced npm script exists" {
|
||||
cat > "$TMPDIR/package.json" <<'EOF'
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Testing
|
||||
- Run `npm run test` before committing.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "fails when AGENTS.md references a stale make target" {
|
||||
cat > "$TMPDIR/Makefile" <<'EOF'
|
||||
build:
|
||||
echo building
|
||||
EOF
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
Run `make deploy` to ship.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "deploy"
|
||||
}
|
||||
|
||||
@test "emits INFO instead of FAIL when there is no package.json to verify an npm script against" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Testing
|
||||
Run `pnpm run e2e` before committing.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
assert_output --partial "INFO"
|
||||
assert_output --partial "e2e"
|
||||
}
|
||||
|
||||
@test "fails when a referenced file path does not exist" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
See `scripts/bootstrap.sh` for environment setup.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "scripts/bootstrap.sh"
|
||||
}
|
||||
|
||||
@test "passes when the referenced file path exists" {
|
||||
mkdir -p "$TMPDIR/scripts"
|
||||
: > "$TMPDIR/scripts/bootstrap.sh"
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
See `scripts/bootstrap.sh` for environment setup.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../" && pwd)"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-support/load"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
|
||||
|
||||
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate-secrets.sh"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
@test "passes on AGENTS.md with no secrets, only placeholders" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
- Set `export API_KEY=$API_KEY`
|
||||
- Token: <your-token-here>
|
||||
- DB: postgres://user:changeme@localhost/db
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
assert_output ""
|
||||
}
|
||||
|
||||
@test "fails on a real-looking AWS access key" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
- AWS_ACCESS_KEY_ID=AKIAABCDEFGHIJKLMNOP # gitleaks:allow (synthetic fixture — this test verifies validate-secrets.sh catches exactly this pattern)
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "AWS access key ID"
|
||||
assert_output --partial "AGENTS.md:4"
|
||||
}
|
||||
|
||||
@test "fails on a credential-bearing connection string" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
- DB: postgres://svc_user:h8x2Klm9pQrT@db.internal:5432/prod
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "connection string"
|
||||
}
|
||||
|
||||
@test "detects secrets in a nested AGENTS.md, not just root" {
|
||||
mkdir -p "$TMPDIR/packages/api"
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
Clean root file.
|
||||
EOF
|
||||
cat > "$TMPDIR/packages/api/AGENTS.md" <<'EOF'
|
||||
# API package
|
||||
- token: ghp_1234567890abcdefghijklmnopqrstuvwxyz01 # gitleaks:allow (synthetic fixture)
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "packages/api/AGENTS.md"
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../" && pwd)"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-support/load"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
|
||||
|
||||
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate-structure.sh"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
@test "fails on an empty AGENTS.md" {
|
||||
: > "$TMPDIR/AGENTS.md"
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "empty"
|
||||
}
|
||||
|
||||
@test "fails on an unfilled placeholder AGENTS.md" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup
|
||||
FILL IN: describe setup commands here.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_failure
|
||||
assert_output --partial "placeholder"
|
||||
}
|
||||
|
||||
@test "passes with INFO on real content missing an optional section" {
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run tests: `pnpm test`
|
||||
|
||||
## Code style
|
||||
- TypeScript strict mode, single quotes, no semicolons.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
assert_output --partial "INFO"
|
||||
assert_output --partial "security"
|
||||
}
|
||||
|
||||
@test "suggests trimming a nested AGENTS.md that duplicates the root file" {
|
||||
mkdir -p "$TMPDIR/packages/api"
|
||||
cat > "$TMPDIR/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run tests: `pnpm test`
|
||||
- Lint: `pnpm lint`
|
||||
- Build: `pnpm build`
|
||||
EOF
|
||||
cat > "$TMPDIR/packages/api/AGENTS.md" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run tests: `pnpm test`
|
||||
- Lint: `pnpm lint`
|
||||
- Build: `pnpm build`
|
||||
EOF
|
||||
run bash "$SCRIPT" "$TMPDIR"
|
||||
assert_success
|
||||
assert_output --partial "SUGGESTION"
|
||||
assert_output --partial "packages/api/AGENTS.md"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# agentsmd-author
|
||||
|
||||
Create or update a target repo's AGENTS.md file(s) by exploring the repo for real conventions.
|
||||
|
||||
## What it does
|
||||
|
||||
Explores a target repo (package manager scripts, Makefile/task runner, CI config, linter config, existing docs) and writes or updates `AGENTS.md` with only verified commands and conventions — never invented ones. Supports nested monorepo placement, following the agents.md standard's nearest-file-wins precedence. Closes every run by invoking `agentsmd-audit` inline, and hands off to `provider-adapter-author` when an existing provider-specific file (CLAUDE.md, etc.) now duplicates content AGENTS.md owns.
|
||||
|
||||
## Before you start
|
||||
|
||||
The `agentsmd-audit` skill must be available (co-installed in the `core` plugin) — this skill invokes it as a mandatory closeout step.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/agentsmd-author
|
||||
```
|
||||
|
||||
Provide the target repo root (defaults to the current directory) and, if relevant, which subdirectory should get a nested AGENTS.md.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `references/content-guide.md` | Section-by-section AGENTS.md content guidance, a worked example, and monorepo/nested-file precedence rules |
|
||||
| `references/sources.md` | Provenance record — sources that informed this skill and which files each contributed to |
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: agentsmd-author
|
||||
description: >
|
||||
Use when the user wants to create or update a repo's AGENTS.md file
|
||||
("write an AGENTS.md for this repo", "add setup/test instructions for
|
||||
agents", "update AGENTS.md", "give this package its own AGENTS.md") — even
|
||||
if they don't name the file explicitly, e.g. "document this for AI coding
|
||||
tools" or "make sure agents know how to run tests here". Writes/updates
|
||||
AGENTS.md by exploring the target repo for real build, test, lint, and
|
||||
style conventions — never invents commands. Supports nested monorepo
|
||||
placement (a subdirectory can get its own AGENTS.md following
|
||||
nearest-file-wins precedence). Closes every run by invoking agentsmd-audit
|
||||
inline, and calls provider-adapter-author when an existing provider file
|
||||
(CLAUDE.md, etc.) now duplicates what AGENTS.md owns. Do not use to review
|
||||
an existing AGENTS.md without changing it — use agentsmd-audit instead. Do
|
||||
not use to convert CLAUDE.md/.cursor/rules into a thin adapter — use
|
||||
provider-adapter-author instead.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
category: docs
|
||||
source_keys:
|
||||
- agents-md-official
|
||||
- context7-websites-agents-md
|
||||
- context7-agentsmd-agents-md
|
||||
version: "0.1.1"
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Never invent a command. Every line under a setup/test/build section must come from something you actually found in the repo (`package.json` scripts, a `Makefile` target, a CI workflow step, a README). If you can't verify a command, don't include it.
|
||||
- AGENTS.md has no required schema — don't force every common-sections-checklist heading into every repo. Include only sections that reflect something real about this repo; a thin, accurate file beats a padded, generic one.
|
||||
- Nested placement is for genuinely different conventions, not convenience. Only create a subdirectory AGENTS.md when that subtree has its own build tool, stack, or conventions distinct from the root — otherwise you're duplicating content the root already covers, which the nearest-file-wins rule doesn't merge back together.
|
||||
- This skill never touches CLAUDE.md, `.cursor/rules/*.mdc`, `copilot-instructions.md`, or similar provider files directly — that's `provider-adapter-author`'s job. Detect and hand off; don't reconcile it yourself.
|
||||
- This skill never audits on its own judgment — the closing `agentsmd-audit` invocation is mandatory, not optional, even when the change looks trivial.
|
||||
|
||||
## Step 1 — Explore the target repo
|
||||
|
||||
Before writing anything, gather real facts: package manager and scripts (`package.json`, `pyproject.toml`, `Cargo.toml`, etc.), a `Makefile` or task runner, CI config (`.github/workflows/`, etc.) for the commands it actually runs, linter/formatter config files, and any existing docs (`README.md`, existing `AGENTS.md`) describing conventions. Note whether any subdirectory looks like its own package with a different stack.
|
||||
|
||||
## Step 2 — Decide placement
|
||||
|
||||
- No `AGENTS.md` at the repo root yet → create one there first, covering whole-repo conventions.
|
||||
- A subdirectory has materially different build/test tooling or conventions than the root → create or update a nested `AGENTS.md` there, scoped to what's different. Don't repeat root-level content — the nearest-file-wins rule means the nested file is read alone, not merged with the root.
|
||||
- Otherwise → update the existing file(s) in place.
|
||||
|
||||
## Step 3 — Write or update
|
||||
|
||||
Use only sections that reflect something real about the repo — never fill in every common-sections-checklist heading just because it exists. Read `references/content-guide.md` for section-by-section guidance, a worked example, and what separates useful content from generic padding, before writing.
|
||||
|
||||
## Step 4 — Check for an existing provider file
|
||||
|
||||
Look for `CLAUDE.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, or similar in the target repo. If one exists and now duplicates content the AGENTS.md you just wrote/updated already owns, invoke the `provider-adapter-author` skill on it to reconcile — don't rewrite it yourself.
|
||||
|
||||
## Step 5 — Audit and report
|
||||
|
||||
Invoke the `agentsmd-audit` skill directly on the AGENTS.md file(s) you just wrote or updated. Resolve any FAIL findings before considering the work done — re-invoke this skill's own writing steps to fix them, then re-run the audit, same as any other close-the-loop check. Report what was created/changed, whether a provider file was reconciled, and the audit's final result.
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
source_keys:
|
||||
- agents-md-official
|
||||
- context7-websites-agents-md
|
||||
- context7-agentsmd-agents-md
|
||||
---
|
||||
|
||||
# What good AGENTS.md content looks like
|
||||
|
||||
AGENTS.md has no required schema — there's no field to fill in, only sections that either
|
||||
earn their place or don't. Agents treat this file as a set of live directives, not
|
||||
documentation: they will actually run the commands it lists and fix failures before
|
||||
finishing a task. That means a wrong or stale line is worse than a missing one. Verify
|
||||
every command against something real in the repo before writing it down.
|
||||
|
||||
## Section-by-section guidance
|
||||
|
||||
**Setup / build commands** — the install and dev-server commands, exactly as they appear
|
||||
in `package.json` scripts, a `Makefile`, or a `Cargo.toml`/`pyproject.toml` equivalent. One
|
||||
line per command, each with a one-clause note on what it does if the name alone isn't
|
||||
obvious. Skip this section if there's genuinely nothing beyond "clone and run" — don't pad
|
||||
it with a restated `git clone`.
|
||||
|
||||
**Code style** — only conventions that aren't already enforced by a linter/formatter config
|
||||
the agent will pick up on its own (a `.eslintrc`, `rustfmt.toml`, etc. speaks for itself).
|
||||
Write down the conventions that live only in people's heads: naming patterns, module
|
||||
boundaries, patterns to avoid, anything a linter can't catch. If the repo has no
|
||||
undocumented conventions beyond what tooling enforces, skip this section.
|
||||
|
||||
**Testing instructions** — the exact command(s) to run the suite, where to find
|
||||
per-package or per-workflow test configuration (e.g. `.github/workflows/`), and any
|
||||
non-obvious requirement (a service that must be running, an env var that must be set).
|
||||
State plainly that the agent should run tests before considering a change done and fix
|
||||
failures — don't leave this implicit.
|
||||
|
||||
**Security considerations** — only repo-specific hazards: a data-handling boundary, a
|
||||
credential pattern to never hardcode, a destructive command that needs a confirmation
|
||||
step. Do not restate general security advice ("don't commit secrets") that any agent
|
||||
already assumes — that's padding, not a directive.
|
||||
|
||||
**Commit / PR conventions** — the title/format convention if one exists (e.g. a
|
||||
Conventional Commits type prefix, a ticket-number requirement), and any check that must
|
||||
pass before a PR is opened (lint, test, type-check). Point at the real command, not
|
||||
"make sure it passes."
|
||||
|
||||
**Dev environment tips** — the handful of things that save real time and are easy to miss:
|
||||
how to jump to a specific package in a monorepo without `ls`-ing around, how to register a
|
||||
new package so the toolchain sees it, where to look up a canonical name/id. This section
|
||||
is for genuine friction points observed in this repo, not generic advice.
|
||||
|
||||
## What separates useful content from padding
|
||||
|
||||
A useful section names a real file, command, or path that exists in this repo right now.
|
||||
A padded section could be pasted into any repo unchanged and still "make sense" — that's
|
||||
the tell. If a sentence would read the same in a different codebase, it doesn't belong.
|
||||
Prefer four accurate lines over twelve generic ones.
|
||||
|
||||
## Worked example (minimal project)
|
||||
|
||||
```markdown
|
||||
# AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Start dev server: `pnpm dev`
|
||||
- Run tests: `pnpm test`
|
||||
|
||||
## Code style
|
||||
- TypeScript strict mode
|
||||
- Single quotes, no semicolons
|
||||
- Use functional patterns where possible
|
||||
|
||||
## Dev environment tips
|
||||
- Use `pnpm dlx turbo run where <project_name>` to jump to a package instead of scanning with `ls`.
|
||||
- Run `pnpm install --filter <project_name>` to add the package to your workspace so Vite, ESLint, and TypeScript can see it.
|
||||
- Check the `name` field inside each package's `package.json` to confirm the right name.
|
||||
|
||||
## Testing instructions
|
||||
- Find the CI plan in the `.github/workflows` folder.
|
||||
- Run `pnpm turbo run test --filter <project_name>` to run every check defined for that package.
|
||||
- From the package root you can just call `pnpm test`. The commit should pass all tests before you merge.
|
||||
- Fix any test or type errors until the whole suite is green.
|
||||
- Add or update tests for the code you change, even if nobody asked.
|
||||
|
||||
## PR instructions
|
||||
- Title format: [<project_name>] <Title>
|
||||
- Always run `pnpm lint` and `pnpm test` before committing.
|
||||
```
|
||||
|
||||
Every line above names a real command or path — that's the standard to hold this repo's
|
||||
version to, not the specific tooling shown (a Python/Cargo/Go repo's AGENTS.md should look
|
||||
nothing like this one in its specifics, only in how concrete each line is).
|
||||
|
||||
## Monorepo / nested placement
|
||||
|
||||
```
|
||||
my-monorepo/
|
||||
├── AGENTS.md # Root-level: applies to the whole repo
|
||||
├── packages/
|
||||
│ ├── api/
|
||||
│ │ └── AGENTS.md # API-specific instructions; overrides root for this package
|
||||
│ ├── web/
|
||||
│ │ └── AGENTS.md # Web app-specific instructions
|
||||
│ └── shared/
|
||||
│ └── AGENTS.md # Shared library instructions
|
||||
```
|
||||
|
||||
Precedence rule: the file nearest the edited path wins. Nested files are **not** merged
|
||||
with the root file — an agent editing inside `packages/api/` reads only
|
||||
`packages/api/AGENTS.md`, never the root file in addition. Consequences:
|
||||
|
||||
- A nested file must stand alone. Don't write "also see the root file" — write what the
|
||||
agent needs, full stop.
|
||||
- Don't duplicate root content in a nested file "just in case." If a nested file repeats
|
||||
root-level setup instructions verbatim, that's a sign it shouldn't exist as a separate
|
||||
file at all — the subtree isn't actually different enough to warrant one.
|
||||
- Only create a nested file when the subtree has a genuinely different stack, build tool,
|
||||
or convention than the root (see `SKILL.md` Step 2 for the placement decision itself).
|
||||
@@ -1,25 +0,0 @@
|
||||
# Sources
|
||||
|
||||
## agents-md-official
|
||||
|
||||
- **URL:** https://agents.md/
|
||||
- **Description:** Official agents.md website — format spec, common-sections checklist, precedence rules (nearest-file-wins, no merge across files), monorepo nesting patterns
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md, references/content-guide.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-websites-agents-md
|
||||
|
||||
- **URL:** context7:/websites/agents_md
|
||||
- **Description:** Context7 index of the official agents.md website — overview, governance, cross-tool compatibility, configuration examples
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md, references/content-guide.md
|
||||
- **Status:** `extracted`
|
||||
|
||||
## context7-agentsmd-agents-md
|
||||
|
||||
- **URL:** context7:/agentsmd/agents.md
|
||||
- **Description:** Context7 index of the agentsmd/agents.md repository — format spec, nested monorepo patterns, file structure examples
|
||||
- **Research doc:** plugins/core/docs/research/docs/agentsmd/sources.md
|
||||
- **Contributing files:** SKILL.md, references/content-guide.md
|
||||
- **Status:** `extracted`
|
||||
@@ -1,30 +0,0 @@
|
||||
# provider-adapter-author
|
||||
|
||||
Convert a target repo's provider-specific instruction file (CLAUDE.md, .cursor/rules, copilot-instructions.md, etc.) into a thin adapter over AGENTS.md.
|
||||
|
||||
## What it does
|
||||
|
||||
Detects a provider-specific AI instruction file in a target repo, diffs it against the repo's `AGENTS.md`, and rewrites it down to a minimal reference — an `@AGENTS.md`-style import for providers that support one, or a text pointer for those that don't — plus only genuinely provider-specific additions. Self-validates its own output with a bundled deterministic script (no LLM judgment, no separate audit skill) before finishing.
|
||||
|
||||
## Before you start
|
||||
|
||||
The target repo must already have an `AGENTS.md`. If it doesn't, run `agentsmd-author` first — this skill never creates or edits `AGENTS.md` itself.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/provider-adapter-author
|
||||
```
|
||||
|
||||
Provide the path to the provider-specific file to convert (and the target repo root, if not inferable). Can be invoked directly, or composed into by `agentsmd-author` when it detects an existing provider file with content overlapping AGENTS.md.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on |
|
||||
| `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-adapter.bats` | Bats test suite for `scripts/validate-adapter.sh` |
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: provider-adapter-author
|
||||
description: >
|
||||
Use when the user wants to convert a provider-specific AI instruction file
|
||||
(CLAUDE.md, .cursor/rules/*.mdc, copilot-instructions.md, etc.) into a
|
||||
thin adapter that defers to a repo's AGENTS.md — e.g. "reduce duplication
|
||||
between CLAUDE.md and AGENTS.md", "make CLAUDE.md just import AGENTS.md"
|
||||
— even if the pattern isn't named explicitly. Also invoke when
|
||||
agentsmd-author detects an existing provider file overlapping with
|
||||
AGENTS.md it just wrote. Detects redundant content in a provider file
|
||||
relative to AGENTS.md and rewrites it down to a minimal reference (an
|
||||
`@AGENTS.md`-style import where supported, or a text pointer otherwise)
|
||||
plus genuinely provider-specific additions. Self-validates via a bundled
|
||||
deterministic script before finishing. Do not use to write or audit
|
||||
AGENTS.md itself — use agentsmd-author or agentsmd-audit.
|
||||
allowed-tools: Bash Read Edit Write
|
||||
metadata:
|
||||
category: docs
|
||||
source_keys:
|
||||
- adr-0002-0003-two-tier-claude-md
|
||||
version: "0.1.0"
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Not every provider supports cross-file imports. Claude Code does — a `CLAUDE.md` can consist of nothing but one or more `@path` lines (e.g. `@AGENTS.md`), with no other content required. Cursor's `.cursor/rules/*.mdc` and GitHub Copilot's `copilot-instructions.md` have no native import mechanism as of current tooling — for those, "thin" means a short text pointer to AGENTS.md plus only what that tool actually needs, not a literal import line. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for these providers.
|
||||
- This skill never creates or edits `AGENTS.md` itself. If the target repo has no `AGENTS.md` yet, stop and point the user to `agentsmd-author` first — there's nothing to adapt to.
|
||||
- Only strip content from the provider file that's genuinely redundant with AGENTS.md. Provider-specific material (IDE settings, tool-only syntax, model-specific instructions) stays — the goal is thin, not empty.
|
||||
- Works standalone or composed-into by `agentsmd-author` — behave identically either way; don't assume a caller skill exists.
|
||||
|
||||
## Step 1 — Detect
|
||||
|
||||
Look for known provider instruction files in the target repo: `CLAUDE.md` (repo root, and any deployed copies), `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, and similar tool-specific files. Confirm `AGENTS.md` exists at the repo root — if not, stop and tell the user to run `agentsmd-author` first.
|
||||
|
||||
## Step 2 — Diff and rewrite
|
||||
|
||||
Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file:
|
||||
|
||||
- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it.
|
||||
- **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket.
|
||||
|
||||
## Step 3 — Self-validate
|
||||
|
||||
Run the bundled check before finishing — this is the skill's own closeout gate; there is no separate paired audit skill for this concern:
|
||||
|
||||
```bash
|
||||
bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file>
|
||||
```
|
||||
|
||||
Fix any `FAIL` and re-run until it exits `0`.
|
||||
|
||||
## Step 4 — Report
|
||||
|
||||
State which file was converted, what was removed versus kept, and the validator's final result.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Sources
|
||||
|
||||
## adr-0002-0003-two-tier-claude-md
|
||||
|
||||
- **URL:** (in-repo precedent — not an external source or plugin research corpus entry)
|
||||
- **Description:** This repo's own two-tier CLAUDE.md/AGENTS.md pattern: AGENTS.md is the provider-agnostic source of always-on rules; provider-specific files (CLAUDE.md) become thin adapters that import it (`@AGENTS.md` plus provider-specific additions). Grounds this skill's entire adapter-conversion design — the "thin adapter" shape, the `@`-import convention, and the size/duplication expectations enforced by `scripts/validate-adapter.sh`.
|
||||
- **Research doc:** docs/adr/0002-two-tier-claude-md.md, docs/adr/0003-agents-md-provider-agnostic-entry-point.md, providers/claude-code/CLAUDE.md (in-repo ADRs and a live example, not a plugin research corpus entry; referenced here since this skill's design is modeled directly on an existing implementation rather than external research)
|
||||
- **Contributing files:** SKILL.md
|
||||
- **Status:** `extracted`
|
||||
@@ -1,9 +0,0 @@
|
||||
# scripts/
|
||||
|
||||
Deterministic self-check this skill shells out to instead of relying on LLM judgment for a mechanical check.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `validate-adapter.sh` | Checks a rewritten provider file (CLAUDE.md, etc.) has a reference to AGENTS.md, doesn't duplicate its content, and stays under a thin-file line threshold |
|
||||
|
||||
Takes `<adapter-file> <agents-md-file>`, with optional `--no-import-syntax` and `--max-lines N` flags. Prints `FAIL` findings to stdout and exits non-zero on any failure.
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: validate-adapter.sh [--no-import-syntax] [--max-lines N] <adapter-file> <agents-md-file>
|
||||
|
||||
Self-check gate for provider-adapter-author. Checks that a rewritten
|
||||
provider-specific instruction file (CLAUDE.md, .cursor/rules/*.mdc,
|
||||
copilot-instructions.md, etc.) is actually a thin adapter over AGENTS.md,
|
||||
not a duplicate copy of it.
|
||||
|
||||
Arguments:
|
||||
adapter-file Path to the provider-specific file to check.
|
||||
agents-md-file Path to the AGENTS.md file it should defer to.
|
||||
|
||||
Options:
|
||||
--no-import-syntax The target provider has no native cross-file import
|
||||
mechanism. Accept a plain-text pointer mention of
|
||||
"AGENTS.md" instead of requiring an @import-style line.
|
||||
--max-lines N Max non-blank lines allowed in the adapter file before
|
||||
it's considered no longer "thin". Default: 60.
|
||||
--help, -h Show this help and exit 0.
|
||||
|
||||
Exit codes:
|
||||
0 Adapter file passes all checks
|
||||
1 One or more checks failed (empty file, no reference to AGENTS.md,
|
||||
excessive duplication, or file too long)
|
||||
EOF
|
||||
}
|
||||
|
||||
NO_IMPORT_SYNTAX=0
|
||||
MAX_LINES=60
|
||||
ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--no-import-syntax)
|
||||
NO_IMPORT_SYNTAX=1
|
||||
shift
|
||||
;;
|
||||
--max-lines)
|
||||
MAX_LINES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#ARGS[@]} -lt 2 ]]; then
|
||||
echo "Error: adapter-file and agents-md-file are required." >&2
|
||||
echo "" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
adapter_path, agents_md_path, no_import_syntax, max_lines = sys.argv[1:5]
|
||||
no_import_syntax = no_import_syntax == "1"
|
||||
max_lines = int(max_lines)
|
||||
|
||||
if not os.path.isfile(adapter_path):
|
||||
print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not os.path.isfile(agents_md_path):
|
||||
print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(adapter_path, encoding="utf-8", errors="replace") as f:
|
||||
adapter_content = f.read()
|
||||
with open(agents_md_path, encoding="utf-8", errors="replace") as f:
|
||||
agents_md_content = f.read()
|
||||
|
||||
has_fail = False
|
||||
|
||||
if not adapter_content.strip():
|
||||
print(f"FAIL Adapter file is empty — {adapter_path}")
|
||||
print(" Why: An empty adapter carries no reference to AGENTS.md and no provider-specific content.")
|
||||
print(" Fix: Add at least an import (or text pointer) to AGENTS.md.")
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
IMPORT_RE = re.compile(r'(?m)^\s*@\S*AGENTS\.md\s*$')
|
||||
lines = adapter_content.splitlines()
|
||||
import_lines = [ln for ln in lines if IMPORT_RE.match(ln)]
|
||||
|
||||
if no_import_syntax:
|
||||
has_reference = "AGENTS.md" in adapter_content
|
||||
else:
|
||||
has_reference = bool(import_lines) or "AGENTS.md" in adapter_content
|
||||
|
||||
if not has_reference:
|
||||
has_fail = True
|
||||
print(f"FAIL Adapter has no reference to AGENTS.md — {adapter_path}")
|
||||
if no_import_syntax:
|
||||
print(" Why: This provider has no import syntax, so the adapter must at least mention AGENTS.md as a text pointer.")
|
||||
print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"")
|
||||
else:
|
||||
print(" Why: A thin adapter must import AGENTS.md (e.g. `@AGENTS.md`) rather than silently omitting it.")
|
||||
print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line.")
|
||||
print()
|
||||
|
||||
# --- Duplication check ---
|
||||
non_import_lines = [ln for ln in lines if not IMPORT_RE.match(ln)]
|
||||
adapter_lines = [ln.strip() for ln in non_import_lines if ln.strip()]
|
||||
agents_lines = {ln.strip() for ln in agents_md_content.splitlines() if ln.strip()}
|
||||
|
||||
if adapter_lines:
|
||||
overlap = sum(1 for ln in adapter_lines if ln in agents_lines)
|
||||
ratio = overlap / len(adapter_lines)
|
||||
if ratio > 0.3:
|
||||
has_fail = True
|
||||
print(f"FAIL Adapter duplicates AGENTS.md content — {adapter_path}")
|
||||
print(f" Why: {ratio:.0%} of the adapter's non-import lines already appear verbatim in AGENTS.md. A thin adapter should import shared content, not restate it.")
|
||||
print(" Fix: Remove the duplicated lines and rely on the AGENTS.md import (or pointer) instead.")
|
||||
print()
|
||||
|
||||
# --- Size check ---
|
||||
non_blank_count = len([ln for ln in lines if ln.strip()])
|
||||
if non_blank_count > max_lines:
|
||||
has_fail = True
|
||||
print(f"FAIL Adapter is not thin — {adapter_path}")
|
||||
print(f" Why: {non_blank_count} non-blank lines exceeds the {max_lines}-line threshold for a thin adapter.")
|
||||
print(" Fix: Move provider-agnostic content into AGENTS.md; keep only genuinely provider-specific additions here.")
|
||||
print()
|
||||
|
||||
if has_fail:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PYTHON
|
||||
@@ -1,28 +0,0 @@
|
||||
# tests/
|
||||
|
||||
Test files for scripts bundled with this skill.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tests require [bats-support](https://github.com/bats-core/bats-support) and
|
||||
[bats-assert](https://github.com/bats-core/bats-assert). The test files load
|
||||
helpers from the repo root's `tests/test_helper/`.
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bats-core/bats-support tests/test_helper/bats-support
|
||||
git clone https://github.com/bats-core/bats-assert tests/test_helper/bats-assert
|
||||
```
|
||||
|
||||
Run all tests for this skill (from the repo root):
|
||||
|
||||
```bash
|
||||
bats plugins/core/skills/provider-adapter-author/tests/
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `validate-adapter.bats` | Bats test suite for `scripts/validate-adapter.sh` |
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../../../" && pwd)"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-support/load"
|
||||
load "$REPO_ROOT/tests/test_helper/bats-assert/load"
|
||||
|
||||
SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate-adapter.sh"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
AGENTS_MD="$TMPDIR/AGENTS.md"
|
||||
cat > "$AGENTS_MD" <<'EOF'
|
||||
# AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run tests: `pnpm test`
|
||||
|
||||
## Code style
|
||||
- TypeScript strict mode, single quotes, no semicolons.
|
||||
EOF
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
@test "fails when the adapter file is empty" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
: > "$ADAPTER"
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "empty"
|
||||
}
|
||||
|
||||
@test "fails when the adapter has no reference to AGENTS.md" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
cat > "$ADAPTER" <<'EOF'
|
||||
# Claude-specific notes
|
||||
Use the internal linter before committing.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "no reference"
|
||||
}
|
||||
|
||||
@test "passes a thin adapter with an @import line and provider-specific additions" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
cat > "$ADAPTER" <<'EOF'
|
||||
@AGENTS.md
|
||||
@core/instructions/governance.md
|
||||
EOF
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "fails when the adapter duplicates most of AGENTS.md's content" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
cat > "$ADAPTER" <<'EOF'
|
||||
@AGENTS.md
|
||||
|
||||
## Setup commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run tests: `pnpm test`
|
||||
|
||||
## Code style
|
||||
- TypeScript strict mode, single quotes, no semicolons.
|
||||
EOF
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "duplicat"
|
||||
}
|
||||
|
||||
@test "fails when the adapter exceeds the max line threshold" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
{
|
||||
echo "@AGENTS.md"
|
||||
for i in $(seq 1 80); do echo "Provider-specific line $i unrelated to AGENTS.md content."; done
|
||||
} > "$ADAPTER"
|
||||
run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "thin"
|
||||
}
|
||||
|
||||
@test "allows a custom --max-lines threshold" {
|
||||
ADAPTER="$TMPDIR/CLAUDE.md"
|
||||
{
|
||||
echo "@AGENTS.md"
|
||||
for i in $(seq 1 10); do echo "Provider-specific line $i unrelated to AGENTS.md content."; done
|
||||
} > "$ADAPTER"
|
||||
run bash "$SCRIPT" --max-lines 5 "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "thin"
|
||||
}
|
||||
|
||||
@test "with --no-import-syntax, a text pointer to AGENTS.md is accepted instead of an @import line" {
|
||||
ADAPTER="$TMPDIR/copilot-instructions.md"
|
||||
cat > "$ADAPTER" <<'EOF'
|
||||
See AGENTS.md at the repo root for setup, style, and testing conventions.
|
||||
|
||||
## Copilot-specific
|
||||
Prefer inline suggestions over chat for one-line edits.
|
||||
EOF
|
||||
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
|
||||
assert_success
|
||||
}
|
||||
|
||||
@test "with --no-import-syntax, still fails if there is no mention of AGENTS.md at all" {
|
||||
ADAPTER="$TMPDIR/copilot-instructions.md"
|
||||
cat > "$ADAPTER" <<'EOF'
|
||||
## Copilot-specific
|
||||
Prefer inline suggestions over chat for one-line edits.
|
||||
EOF
|
||||
run bash "$SCRIPT" --no-import-syntax "$ADAPTER" "$AGENTS_MD"
|
||||
assert_failure
|
||||
assert_output --partial "no reference"
|
||||
}
|
||||
|
||||
@test "--help exits 0 and documents usage" {
|
||||
run bash "$SCRIPT" --help
|
||||
assert_success
|
||||
assert_output --partial "Usage:"
|
||||
}
|
||||
|
||||
@test "fails with a clear error when the adapter file argument is missing" {
|
||||
run bash "$SCRIPT"
|
||||
assert_failure
|
||||
assert_output --partial "required"
|
||||
}
|
||||
@@ -15,5 +15,5 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"name": "gitea",
|
||||
"version": "1.3.3"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"agents": "agents/",
|
||||
"author": {
|
||||
"email": "defame1297@rkdr.net",
|
||||
"name": "Defame1297"
|
||||
@@ -20,5 +19,5 @@
|
||||
"skills": [
|
||||
"skills/"
|
||||
],
|
||||
"version": "1.3.3"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ explicit confirmation, and treating unexpected 404s as possible masked 403s).
|
||||
|
||||
## Before you start
|
||||
|
||||
Requires a Gitea MCP server configured with a token that has `write:repository` scope. This is
|
||||
confirmed for `list_branches`, `create_branch`, and `delete_branch` (Gitea gates reads behind write
|
||||
scope for repo-scoped operations); `list_commits` and `get_commit` are inferred to need the same
|
||||
scope by analogy, not explicitly confirmed — see `references/commits.md`. Requires a git remote
|
||||
named `origin` pointing at the Gitea instance.
|
||||
Requires a Gitea MCP server configured with a token. `list_branches`, `list_commits`, and
|
||||
`get_commit` work with `write:issue` alone; `create_branch` and `delete_branch` need
|
||||
`write:repository`. Requires a git remote named `origin` pointing at the Gitea instance.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ description: >
|
||||
git-history) or for PR-side branch references like cross-repo fork PR heads
|
||||
(use gitea-prs).
|
||||
|
||||
compatibility: Requires Gitea MCP server configured with a token with write:repository scope; this is confirmed to gate list_branches, create_branch, and delete_branch (Gitea gates reads behind write scope for repo-scoped operations), and is inferred by analogy (not explicitly confirmed by source docs) to also gate list_commits and get_commit. Requires git remote "origin" pointing to the Gitea instance.
|
||||
compatibility: Requires Gitea MCP server configured with a token; list_branches, list_commits, and get_commit work with write:issue alone, create_branch and delete_branch require write:repository. Requires git remote "origin" pointing to the Gitea instance.
|
||||
|
||||
metadata:
|
||||
category: integration
|
||||
version: "0.1.1"
|
||||
version: "0.1.0"
|
||||
source_keys:
|
||||
- gitea-mcp-repo
|
||||
- gitea-mcp-slim-go
|
||||
|
||||
@@ -73,9 +73,6 @@ protected, every time, regardless of how the request is phrased.
|
||||
|
||||
## Token scope
|
||||
|
||||
All three — `list_branches`, `create_branch`, `delete_branch` — require `write:repository`. Gitea
|
||||
gates reads behind write scope for repo-scoped operations, so `list_branches` needs the same scope
|
||||
as the write operations, not `write:issue` alone. An earlier version of this doc claimed
|
||||
`write:issue` alone was sufficient for `list_branches`, based on empirical testing under a token
|
||||
that held both `write:issue` and `write:repository` simultaneously — that test didn't isolate the
|
||||
variable, so it couldn't actually establish `write:issue` alone as sufficient.
|
||||
`list_branches` works with `write:issue` alone. `create_branch` and `delete_branch` need
|
||||
`write:repository`. All three are verified working empirically under a token with both scopes
|
||||
(`write:issue` + `write:repository`).
|
||||
|
||||
@@ -63,11 +63,5 @@ edge cases, `get_commit` will not.
|
||||
|
||||
## Token scope
|
||||
|
||||
Both tools are believed to require `write:repository`, even though they're read-only — inferred by
|
||||
analogy with the scope-gating principle in `overview.md` (Gitea gates reads behind write scope for
|
||||
repo-scoped operations), not a claim `overview.md` makes for commits by name: its explicit
|
||||
`write:repository` enumeration lists PR, branch, file, release, and tag operations, but doesn't
|
||||
mention commits. An earlier version of this doc claimed `write:issue` alone worked, based on
|
||||
empirical testing under a token that held both `write:issue` and `write:repository`
|
||||
simultaneously — that test didn't isolate the variable either. Treat this as unverified until
|
||||
tested under a token scoped to `write:issue` only (no `write:repository`).
|
||||
Both tools are read-only and work with `write:issue` alone (no `write:repository` needed), verified
|
||||
empirically against the deployed server.
|
||||
|
||||
@@ -28,10 +28,7 @@ allowed-tools: mcp__gitea__get_file_contents mcp__gitea__get_dir_contents mcp__g
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **A 404 from any read call may actually be a 403 in disguise.** `get_file_contents`, `get_dir_contents`, and `get_repository_tree` all gate on `write:repository` scope, not just read access — some Gitea endpoints return 404 instead of 403 when the token's scope is insufficient, to avoid leaking whether the resource exists. If a read fails with 404 on a path you're confident is correct, check the token's configured scopes before concluding the file or directory doesn't exist.
|
||||
- **SHA is the concurrency token for every write — and it lives at the top level of `get_file_contents`'s response, not nested under `content`.** `create_or_update_file` without `sha` is always treated as a *create*: if the path already exists, Gitea returns HTTP 409. `delete_file` has no optional path at all — omitting `sha` returns HTTP 422. The safe sequence for any update or delete is always: call `get_file_contents` first, read the top-level `sha` field, then pass that exact value to the write call. Never guess or reuse a stale SHA — a mismatched SHA is rejected the same as a missing one.
|
||||
- **A write can also fail because the branch requires signed commits — a separate failure mode from a bad SHA.** `create_or_update_file` and `delete_file` create commits server-side via a bare API token call with no 2FA/PGP context. If the target branch's protection rule requires signed commits, Gitea rejects the write outright — surfaced as a generic 403 or 422, not an error naming "signed commit required," and reads against that same branch keep succeeding right up until you try to write. When a write fails without a clean 409 (missing/stale SHA) or 404 (bad path) explanation, check whether the branch's protection rule requires signed commits before assuming the SHA is wrong and retrying.
|
||||
- **A large `create_or_update_file` payload can hit a reverse-proxy 413 that has nothing to do with Gitea.** `content` is base64-encoded, which inflates the payload ~33% over the raw file size; a 413 is commonly a reverse-proxy body-size limit in front of the Gitea instance, not a Gitea-side rejection. No amount of retrying, or changing the SHA, path, or branch, will fix it — it needs the proxy's config raised, which is outside this skill's or the calling agent's control. Surface that distinction to the user instead of retrying the same call.
|
||||
- **`get_dir_contents` and `get_repository_tree` are not SHA sources for a specific file's write.** `get_dir_contents` entries carry no `sha` at all. `get_repository_tree` entries do carry a `sha` (a blob/tree hash), but fetching it means an extra round trip with no content — `get_file_contents` is the canonical path since it returns the decoded content and the write-ready `sha` in one call.
|
||||
- **`owner` and `repo` are always caller-supplied inputs, never resolved here.** This skill doesn't infer them from a git remote. If invoked directly by a human, ask for them if not stated. If invoked by `gitea-workflow` or an orchestrating agent, expect them to already be resolved and passed in.
|
||||
- **Direct commits to a branch are a first-class action, not a workaround.** Gitea's own web UI defaults to editing files directly against a branch — `create_or_update_file`/`delete_file` used that way is normal, not an API escape hatch to avoid. The SHA-currency requirement above is the actual risk to manage, not the act of committing directly.
|
||||
|
||||
@@ -34,13 +34,3 @@
|
||||
|
||||
**Contributing files:**
|
||||
- SKILL.md (Gotchas — "Direct commits to a branch are a first-class action, not a workaround")
|
||||
|
||||
## context7-gitea-tea-cli
|
||||
|
||||
**Description:** Official `tea` CLI (reference Gitea client) docs on Context7 — practitioner command patterns for issues, PRs, and releases, including semver tag/release conventions, draft/prerelease flags, and release-notes-from-file conventions. Consulted alongside `context7-websites-gitea` while researching `workflow-conventions.md` (both sources contribute to that research doc, backing `gitea-workflow`); its file-command patterns did not end up informing any gitea-files content.
|
||||
|
||||
**Source:** context7:/git_gitea_com/gitea_tea
|
||||
|
||||
- **Research doc:** plugins/gitea/docs/research/docs/gitea/sources.md
|
||||
|
||||
**Contributing files:** (none)
|
||||
|
||||
@@ -11,7 +11,7 @@ The create flow closes out four enrichments deferred from issue #6 comment #848:
|
||||
and milestone assignment (both by composing `gitea-labels-milestones`), an assignee workaround for
|
||||
the blocked `get_me` scope, and the "Depends on #N" dependency-linking convention. It supersedes the
|
||||
`issue`/`issue <N>`/`issue close <N>`/`issue comment <N>` dispatch in the old flat
|
||||
`plugins/bin/skills/gitea/SKILL.md`, removed per
|
||||
`plugins/bin/skills/gitea/SKILL.md`, which remains in place untouched as a fallback per
|
||||
`docs/adr/0011-gitea-skill-deep-modules.md`.
|
||||
|
||||
## Before you start
|
||||
|
||||
@@ -31,11 +31,11 @@ allowed-tools: Bash mcp__gitea__list_issues mcp__gitea__issue_read mcp__gitea__i
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`list_issues` has no `type` or `milestones` parameter — despite `api-reference.md` documenting both.** The live MCP schema (re-verified via `ToolSearch` at authoring time — see `references/sources.md`) only accepts `owner`, `repo` (required), `state` (default `"all"`), `labels` (array of label *names*), `since`, `before` (ISO 8601), `page`, `per_page` (default 30). This tool provides no way to filter issues-vs-PRs or by milestone. Since issues and PRs share one number space, `list_issues` results can include PR entries with no client-side filter to exclude them. If you need to know whether a specific number is a PR, call `issue_read method: "get"` and check `is_pull` — that field only appears on the single-item response, never in a list item. This exact drift (a prior skill trusted the research doc's `type` param and broke) is why this skill's reference files were re-verified live rather than copied from `api-reference.md`.
|
||||
- **`list_issues` has no `type` or `milestones` parameter — despite `api-reference.md` documenting both.** The live MCP schema (re-verified via `ToolSearch` at authoring time — see `references/sources.md`) only accepts `owner`, `repo` (required), `state` (default `"all"`), `labels` (array of label *names*), `since`, `before` (ISO 8601), `page`, `per_page` (default 30). There is no way to filter issues-vs-PRs or by milestone through this tool. Since issues and PRs share one number space, `list_issues` results can include PR entries with no client-side filter to exclude them. If you need to know whether a specific number is a PR, call `issue_read method: "get"` and check `is_pull` — that field only appears on the single-item response, never in a list item. This exact drift (a prior skill trusted the research doc's `type` param and broke) is why this skill's reference files were re-verified live rather than copied from `api-reference.md`.
|
||||
- **`search_issues` does have a working `type` filter** (`"issues"` | `"pulls"`) — unlike `list_issues`. Its `labels` parameter is also shaped differently: a comma-separated string, not an array of names.
|
||||
- **Labels are numeric IDs on write, name strings on read.** `issue_write`'s `labels` parameter (used by `add_labels`/`replace_labels`) takes IDs. `list_issues`/`issue_read` return names. Never resolve this yourself — compose `gitea-labels-milestones` (see `references/enrichments.md`) to get IDs.
|
||||
- **Milestone on `issue_read` is `{id, title}`** — an object, not a bare string. This skill only ever needs the `id`. (The bare-title-string case only happens on the PR side, which is `gitea-prs`' problem, not this skill's.)
|
||||
- **Closing-keyword auto-close behavior is plausible but unconfirmed in our research docs.** Our research docs confirm Gitea does NOT auto-close an issue on a plain PR merge (unlike GitHub) — closing keywords like `Fixes #N`/`Closes #N` in a commit message are not documented one way or the other. After a PR merges (a `gitea-prs` operation), always re-check the issue's state here via `issue_read method: "get"` before deciding whether to close it manually — closing an already-closed issue is a harmless no-op, but don't assume a manual close is always needed.
|
||||
- **A closing keyword in a commit message can auto-close an issue without any `issue_write` call.** Gitea has no GitHub-style "merge closes issue" event, but it does parse `Fixes #N`/`Closes #N` in commit messages landing on the default branch. After a PR merges (a `gitea-prs` operation), re-check the issue's state here via `issue_read method: "get"` before deciding whether to close it manually — closing an already-closed issue is a harmless no-op, but don't assume a manual close is always needed.
|
||||
- **Pagination is manual.** `list_issues` and `search_issues` return one page at a time. Iterate `page: 1, 2, ...` until the returned count is less than `per_page`.
|
||||
- **HTTP 404 may actually mean 403.** Gitea hides permission errors as not-found. If a call 404s unexpectedly, verify the token holds `write:issue` scope (see `references/issues.md`'s Token scope note) before concluding the issue doesn't exist.
|
||||
|
||||
@@ -86,7 +86,7 @@ Call `issue_read method: "get_comments" owner: <owner> repo: <repo> issue_number
|
||||
|
||||
### close `<N>`
|
||||
|
||||
Call `issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed"`. No `method: "close"` exists.
|
||||
Call `issue_write method: "update" owner: <owner> repo: <repo> issue_number: <N> state: "closed"`. There is no `method: "close"`.
|
||||
|
||||
### comment `<N>`
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ metadata:
|
||||
- gitea-mcp-slim-go
|
||||
- context7-websites-gitea
|
||||
- context7-gitea-tea-cli
|
||||
version: "0.1.1"
|
||||
version: "0.1.0"
|
||||
|
||||
allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
||||
allowed-tools: mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__milestone_read mcp__gitea__milestone_write
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
@@ -33,21 +33,11 @@ allowed-tools: Bash mcp__gitea__label_read mcp__gitea__label_write mcp__gitea__m
|
||||
- **Milestone representation differs between issues and PRs.** `issue_read` returns `milestone: {id, title}` — an object. `pull_request_read` returns `milestone: "title string"` — title only, no ID. You cannot recover a milestone ID from a PR response directly; call `milestone_read method: "list"` and match by title instead.
|
||||
- **Repo labels and org labels are separate pools, never mixed in one call.** `label_read`/`label_write` take either `owner`+`repo` (repo-scoped methods) or `org` (org-scoped methods) — passing both or neither for a given method is a caller error, not something the schema enforces for you. Repo and org labels can both apply to the same issue, but you list/create/edit them through different method values.
|
||||
- **`milestone_write` accepts `"update"` and `"edit"` as the same operation.** Both method values map to the identical update call. Prefer `"update"` for consistency with `issue_write`/`pull_request_write`.
|
||||
- **`exclusive` is documented as an org-labels-only flag — it isn't what enforces exclusivity here.** Gitea's docs scope the settable/server-enforced `exclusive` flag to org labels only, and the live `label_write` schema for `create_repo_label`/`edit_repo_label` doesn't document accepting it at all. This repo's `Kind/*`, `Priority/*`, `Status/*` groups still behave as one-label-per-scope, but that's a manually-enforced convention this skill implements client-side, not a guaranteed server behavior for repo labels: applying a new label within a scope (e.g. `Priority/High`) must replace any existing label in that same scope, not add alongside it, and nothing on the server enforces that for you. Label inference (see `references/label-inference.md`) must respect this — replace, don't stack.
|
||||
- **A `/` in a label name plus `exclusive: true` means mutual exclusivity, not just a naming convention.** This repo's `Kind/*`, `Priority/*`, `Status/*` labels follow Gitea's native scoped-label feature: applying a new label within a scope (e.g. `Priority/High`) is expected to replace any existing label in that same scope, not add alongside it. Label inference (see `references/label-inference.md`) must respect this — replace, don't stack.
|
||||
- **Pagination is manual on every list call.** `label_read` and `milestone_read` both default to `per_page: 30`. Iterate `page: 1, 2, ...` until the result count is less than `per_page` — there is no cursor or auto-pagination.
|
||||
- **Schema requiredness differs between the two tool families.** `milestone_read`/`milestone_write` have `owner` and `repo` as hard-required parameters (the call fails validation without them). `label_read`/`label_write` only hard-require `method` — `owner`/`repo`/`org` are functionally required per method but not schema-enforced, so passing none produces a runtime error from Gitea, not a client-side validation error.
|
||||
|
||||
## Step 1 — Resolve owner and repo
|
||||
|
||||
Before any tool call, extract `owner` and `repo` from the git remote (skip this if an orchestrating caller already passed them in):
|
||||
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
|
||||
If origin is not set or the URL is not a Gitea URL, stop and report: "No Gitea remote found — set origin to your Gitea instance URL."
|
||||
|
||||
## Step 2 — Dispatch
|
||||
## Dispatch
|
||||
|
||||
| Task | Tool | method |
|
||||
|---|---|---|
|
||||
|
||||
@@ -14,13 +14,9 @@ asks to label something without naming exact labels.
|
||||
|
||||
## Scoped labels are mutually exclusive — replace, don't stack
|
||||
|
||||
Each of `Kind/*`, `Priority/*`, `Status/*` is treated as a scoped-label group by convention (the `/`
|
||||
delimiter naming pattern). Gitea's `exclusive` flag — the mechanism that would let the server itself
|
||||
enforce one-label-per-scope — is documented as an org-labels-only setting, and the repo-level
|
||||
`label_write` methods used here don't accept it at all. So exclusivity within these scopes is a
|
||||
convention this skill enforces client-side, not something the server guarantees: applying a new
|
||||
label within a scope is expected to replace any existing label in that same scope on the target
|
||||
issue/PR, not add alongside it. When inference
|
||||
Each of `Kind/*`, `Priority/*`, `Status/*` is a Gitea scoped-label group (the `/` delimiter plus
|
||||
`exclusive: true` on the label). Applying a new label within a scope is expected to replace any
|
||||
existing label in that same scope on the target issue/PR, not add alongside it. When inference
|
||||
selects a `Priority/High` label and the issue already carries `Priority/Medium`, the write should
|
||||
result in only `Priority/High` remaining — use `replace_labels` scoped to that group's labels, or at
|
||||
minimum remove the superseded label before adding the new one. Never leave two labels from the same
|
||||
|
||||
@@ -20,7 +20,7 @@ metadata:
|
||||
- gitea-mcp-slim-go
|
||||
- context7-websites-gitea
|
||||
- context7-gitea-tea-cli
|
||||
version: "0.1.1"
|
||||
version: "0.1.0"
|
||||
|
||||
allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp__gitea__pull_request_write mcp__gitea__pull_request_review_write
|
||||
---
|
||||
@@ -29,7 +29,7 @@ allowed-tools: mcp__gitea__list_pull_requests mcp__gitea__pull_request_read mcp_
|
||||
|
||||
- **Issues and PRs share one number space.** A number the user mentions (`#42`) might be an issue, not a PR — there is only one counter per repo. If you're not certain, call `pull_request_read method: "get"` and treat a 404 as "this number is an issue, not a PR" (or check `is_pull` on an `issue_read` response first if you already have one).
|
||||
- **`pull_request_read method: "get"` returns `review_scomments`, not `review_comments`.** Source-level typo in gitea-mcp v1.3.0. Never reference `review_comments` — it will always be undefined.
|
||||
- **`draft: true` on create prepends `"WIP:"` to the title.** Gitea has no first-class draft field — it implements draft PRs via title prefix. To un-draft, call `update` and pass the title without the `WIP:` prefix.
|
||||
- **`draft: true` on create prepends `"WIP:"` to the title.** There is no first-class draft field — Gitea implements draft PRs via title prefix. To un-draft, call `update` and pass the title without the `WIP:` prefix.
|
||||
- **Cross-repo fork PRs require `head` as `"fork-owner:branch-name"`.** A bare branch name causes Gitea to search the base repo for it and return 422. Same-repo PRs use a bare branch name.
|
||||
- **PR `milestone` is a bare title string, not `{id, title}`.** Unlike issues, you cannot recover a milestone's ID from a PR response. If you need the ID (e.g. to filter or to pass to another write), call into `gitea-labels-milestones` and match by title via `milestone_read method: "list"`.
|
||||
- **CI status and review/approval state are independent merge gates.** `get_status` only reports CI. Branch-protection rules (required approvals, requested-reviewer coverage, stale-approval handling) are enforced server-side by the merge call itself and will error if unmet — passing CI does not mean the merge will succeed.
|
||||
|
||||
@@ -56,14 +56,14 @@ List responses trim PRs down to summary fields — `head`/`base` are bare ref st
|
||||
- `base` (string, required for `"create"`) — target branch
|
||||
- `assignee` (string, optional) — single login
|
||||
- `assignees` (array of strings, optional) — login names
|
||||
- `milestone` (number, optional) — milestone ID, never a title; settable on both `"create"` and `"update"`
|
||||
- `milestone` (number, optional, for `"update"`) — milestone ID, never a title
|
||||
- `state` (string, optional, for `"update"`) — `"open"` | `"closed"` (no `"all"` — unlike issue state filters)
|
||||
- `allow_maintainer_edit` (boolean, optional, for `"update"`)
|
||||
- `labels` (array of numbers, optional) — label IDs, never names — resolve via `gitea-labels-milestones` first
|
||||
- `deadline` (string, optional) — ISO 8601
|
||||
- `remove_deadline` (boolean, optional)
|
||||
- `reviewers` (array of strings, optional) — login names; settable directly on `"create"`, or use `"add_reviewers"`/`"remove_reviewers"` to adjust reviewers on an already-open PR
|
||||
- `team_reviewers` (array of strings, optional) — same as `reviewers`: settable on `"create"`, or via `"add_reviewers"`/`"remove_reviewers"` post-creation
|
||||
- `reviewers` (array of strings, optional, for `"add_reviewers"`/`"remove_reviewers"`) — login names
|
||||
- `team_reviewers` (array of strings, optional, for `"add_reviewers"`/`"remove_reviewers"`)
|
||||
- `draft` (boolean, optional, for `"create"`) — prepends `"WIP:"` to the title (see Gotcha)
|
||||
|
||||
Merge-specific parameters (`merge_style`, `delete_branch`, `force_merge`, `merge_when_checks_succeed`, `head_commit_id`, `message` as merge commit message) are covered in `references/merging.md`.
|
||||
|
||||
@@ -19,6 +19,6 @@ Describe your release/tag task: list releases, get the latest release, create a
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `references/call-signatures.md` | Tool parameters and response shapes derived from gitea-mcp source (see `references/sources.md`); input params for 3 of the 9 tools additionally live-cross-checked |
|
||||
| `references/call-signatures.md` | Verified tool parameters and response shapes for all 9 release/tag tools |
|
||||
| `references/conventions.md` | Semver/draft/prerelease practitioner conventions and pagination behavior |
|
||||
| `references/sources.md` | Research sources backing the call signatures and conventions |
|
||||
|
||||
@@ -22,8 +22,8 @@ metadata:
|
||||
|
||||
- **`delete_release` takes a numeric `id`, never a tag name.** `delete_tag` is the mirror opposite — it takes the `tag_name` string, never a numeric id. These two tools are asymmetric on purpose; passing a tag name to `delete_release` or a numeric id to `delete_tag` fails. Always resolve the numeric release id via `list_releases` or `get_release` first if you only have a tag name in hand.
|
||||
- **Deleting a release does not delete its tag.** They are separate destructive operations against separate resources — a release is a wrapper (title, notes, draft/prerelease flags, assets) around a tag, not the tag itself. If the intent is to remove both, call `delete_release` and `delete_tag` separately.
|
||||
- **`list_releases`/`list_tags` default to `per_page: 20`**, unlike most other gitea-mcp tools which default to 30. The MCP layer does no auto-pagination — to get a complete result set, loop `page` upward until a page returns fewer than `per_page` results.
|
||||
- **`is_draft`/`is_pre_release` are explicit booleans the caller sets on `create_release` — never inferred from `tag_name`.** Note the input param is `is_draft`, which maps to the `draft` field on the *response* object (see Dispatch table below and `references/call-signatures.md`) — `draft` is never a valid input key. Practitioner convention (per the `tea` CLI) uses `-beta`/`-rc` suffixes for prereleases (e.g. `v2.0.0-beta.1`), but Gitea does not enforce or infer this from the tag string. If the user names a tag that looks like a prerelease, set `is_pre_release: true` explicitly rather than assuming the flag is redundant with the name.
|
||||
- **`list_releases`/`list_tags` default to `per_page: 20`**, unlike most other gitea-mcp tools which default to 30. There is no auto-pagination in the MCP layer — to get a complete result set, loop `page` upward until a page returns fewer than `per_page` results.
|
||||
- **`draft`/`is_pre_release` are explicit booleans the caller sets on `create_release` — never inferred from `tag_name`.** Practitioner convention (per the `tea` CLI) uses `-beta`/`-rc` suffixes for prereleases (e.g. `v2.0.0-beta.1`), but Gitea does not enforce or infer this from the tag string. If the user names a tag that looks like a prerelease, set `is_pre_release: true` explicitly rather than assuming the flag is redundant with the name.
|
||||
- **Tag names are conventionally semver, `v`-prefixed** (`v1.2.0`, `v2.0.0-beta.1`), but this is a practitioner convention, not a Gitea constraint — don't reject or rewrite a caller-supplied tag name that doesn't follow it.
|
||||
|
||||
## Dispatch table
|
||||
@@ -44,9 +44,9 @@ metadata:
|
||||
|
||||
## Workflow
|
||||
|
||||
- [ ] **Creating a release:** Call `create_release` directly with `tag_name` + `target` + `title` — Gitea is assumed to create the underlying tag automatically if `tag_name` doesn't already exist (this is plausible behavior inferred from the API shape, not directly confirmed in the research docs), so a separate `create_tag` call is only needed when you want to tag a commit without wrapping it in a release yet. Verify the tag exists afterward if this matters to the caller. Set `is_pre_release`/`is_draft` explicitly per the Gotchas above; don't leave them to default inference.
|
||||
- [ ] **Creating a release:** Call `create_release` directly with `tag_name` + `target` + `title` — it creates the underlying tag automatically if `tag_name` doesn't already exist, so a separate `create_tag` call is only needed when you want to tag a commit without wrapping it in a release yet. Set `is_pre_release`/`is_draft` explicitly per the Gotchas above; don't leave them to default inference.
|
||||
- [ ] **Deleting a release safely:** Resolve the numeric id first — call `list_releases` (paginate if needed, see Gotchas) or `get_release` if the id is already known, find the entry matching the target `tag_name`, then call `delete_release` with that `id`. Never pass `tag_name` to `delete_release`.
|
||||
- [ ] **Deleting a tag along with its release:** Delete the release first (frees the id lookup), then call `delete_tag` with the `tag_name` separately — confirm both are intended before proceeding, since each is an independent irreversible operation.
|
||||
- [ ] **Listing every page:** If the caller needs all releases or tags (not just the first page), loop `page: 1, 2, 3...` until a response has fewer than `per_page` entries.
|
||||
- [ ] **Listing completely:** If the caller needs all releases or tags (not just the first page), loop `page: 1, 2, 3...` until a response has fewer than `per_page` entries.
|
||||
|
||||
If exact response field shapes or additional conventions are needed, read `references/call-signatures.md` and `references/conventions.md`.
|
||||
|
||||
@@ -7,18 +7,9 @@ source_keys:
|
||||
|
||||
# Release and tag call signatures
|
||||
|
||||
Signatures and response shapes are derived from gitea-mcp source (`operation/*.go` and `slim.go`,
|
||||
see `references/sources.md`) rather than copied from upstream API docs, which can drift from the
|
||||
deployed gitea-mcp version — but this is a source-code extraction, not a live MCP tool call.
|
||||
|
||||
Input parameter schemas for 3 of the 9 tools here — `create_release`, `delete_tag`, and
|
||||
`get_latest_release` — were additionally cross-checked live via `ToolSearch` against the deployed
|
||||
`mcp__gitea__*` tools in session 2026-07-05, and confirmed to match exactly (required/optional
|
||||
params and names). That check covered only input params for those 3 tools, not response shapes,
|
||||
and not the other 6 tools — treat the rest of this document as source-derived, not live-verified.
|
||||
|
||||
`owner` and `repo` are required strings on every tool below and are omitted from the per-tool lists
|
||||
for brevity.
|
||||
Verified against the live MCP tool schemas at authoring time (not copied from upstream API docs,
|
||||
which can drift from the deployed gitea-mcp version). `owner` and `repo` are required strings on
|
||||
every tool below and are omitted from the per-tool lists for brevity.
|
||||
|
||||
## Releases
|
||||
|
||||
@@ -32,12 +23,12 @@ for brevity.
|
||||
|
||||
**`get_latest_release`**
|
||||
- No parameters beyond `owner`/`repo`.
|
||||
- Returns a single release object for the most recently published release. It is assumed (by analogy with typical "latest release" semantics) that this excludes drafts and prereleases, but that exclusion is not directly confirmed by any of the research docs — verify with `list_releases` if the caller depends on this.
|
||||
- Returns a single release object for the most recently published (non-draft, non-prerelease by Gitea's own "latest" definition) release.
|
||||
|
||||
**`create_release`**
|
||||
- Required: `tag_name` (string), `target` (string — branch, tag, or commit SHA to cut the tag from), `title` (string)
|
||||
- Optional: `body` (string — release notes), `is_draft` (boolean), `is_pre_release` (boolean)
|
||||
- Assumed (not confirmed by the research docs) that if `tag_name` doesn't already exist as a tag, Gitea creates it against `target` as part of this call. Verify with `get_tag`/`list_tags` afterward if the caller needs certainty.
|
||||
- If `tag_name` doesn't already exist as a tag, Gitea creates it against `target` as part of this call.
|
||||
|
||||
**`delete_release`**
|
||||
- Required: `id` (number) — same numeric id as `get_release`. Does not accept `tag_name`.
|
||||
@@ -65,7 +56,7 @@ id, tag_name, target, title, body, draft, prerelease, html_url, author, created_
|
||||
|
||||
**`delete_tag`**
|
||||
- Required: `tag_name` (string). Does not accept a numeric id.
|
||||
- Assumed by symmetry with `delete_release` (documented above as not deleting the underlying tag) to also not delete any release wrapping the tag — but this reverse direction is not independently confirmed by the research docs, and is the more dangerous direction to get wrong: an agent might skip an explicit `delete_release` call assuming the release survives. Verify with `list_releases`/`get_release` after calling `delete_tag` rather than assume.
|
||||
- Does not delete any release wrapping the tag.
|
||||
|
||||
## Pagination
|
||||
|
||||
|
||||
@@ -27,15 +27,11 @@ caller-supplied tag name against semver; just pass it through.
|
||||
|
||||
## Draft and prerelease are explicit flags
|
||||
|
||||
`is_draft` and `is_pre_release` are booleans the caller sets directly on `create_release` — Gitea
|
||||
does not infer either from the tag name, even though the `-beta`/`-rc` suffix convention above is
|
||||
commonly used to signal a prerelease to humans. When a user asks to "cut a beta" or "publish a
|
||||
release candidate," set `is_pre_release: true` explicitly in the same call rather than relying on
|
||||
the tag string to carry that meaning.
|
||||
|
||||
Note the input/output naming mismatch: the input param is `is_draft`, but the release object
|
||||
returned by the API uses `draft` (and `prerelease`) as the field names. `draft` is never a valid
|
||||
input key — passing `draft: true` to `create_release` is silently ignored rather than erroring.
|
||||
`draft` and `is_pre_release`/`prerelease` are booleans the caller sets directly on `create_release`
|
||||
— Gitea does not infer either from the tag name, even though the `-beta`/`-rc` suffix convention
|
||||
above is commonly used to signal a prerelease to humans. When a user asks to "cut a beta" or
|
||||
"publish a release candidate," set `is_pre_release: true` explicitly in the same call rather than
|
||||
relying on the tag string to carry that meaning.
|
||||
|
||||
## Release notes sourcing
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"name": "kyberforge",
|
||||
"version": "1.2.8"
|
||||
"version": "1.2.3"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"skills": [
|
||||
"skills/"
|
||||
],
|
||||
"version": "1.2.8"
|
||||
"version": "1.2.3"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ Audits a Claude Code and Copilot agent definition file pair for correctness and
|
||||
|
||||
## What it does
|
||||
|
||||
Accepts either file in a CC `.md` / Copilot `.agent.md` pair, derives the counterpart automatically, and validates both. Runs structural checks via `validate.sh` (required fields, kebab-case name, no placeholders, no CC-only fields in the Copilot file, silently-ignored fields at plugin scope), provenance chain validation via `validate-provenance.sh` (checks `source_keys` against `sources.md` at the plugin root), then qualitative checks on description phrasing and system prompt quality. Step 1 also runs a Vale-based prose sub-check via `vale-wrap.sh` against both files of the pair, using the `Kyberforge` style (both files) and `KyberforgeCopilot` style (Copilot file only) — every alert is a `FAIL`, cited by rule ID — falling back to Step 2 judgment when the `vale` binary is unavailable or reports `0 files` scanned. Produces a compact findings report in the same format as `skill-audit`.
|
||||
Accepts either file in a CC `.md` / Copilot `.agent.md` pair, derives the counterpart automatically, and validates both. Runs structural checks via `validate.sh` (required fields, kebab-case name, no placeholders, no CC-only fields in the Copilot file, silently-ignored fields at plugin scope), provenance chain validation via `validate-provenance.sh` (checks `source_keys` against `sources.md` at the plugin root), then qualitative checks on description phrasing and system prompt quality. Produces a compact findings report in the same format as `skill-audit`.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -19,12 +19,6 @@ Pass the path to either agent file as the argument.
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `assets/vale/.vale.ini` | Vale config: scopes `Kyberforge` to `**/agents/*.md`, `Kyberforge`+`KyberforgeCopilot` to `**/*.agent.md` |
|
||||
| `assets/vale/styles/Kyberforge/DescriptionOpener.yml` | Flags descriptions opening with "This skill/agent" instead of an imperative "Use when..." |
|
||||
| `assets/vale/styles/Kyberforge/PaddingPhrase.yml` | Flags generic "see references/ for info" pointers instead of specific file references |
|
||||
| `assets/vale/styles/Kyberforge/SentenceOpenerThereIs.yml` | Flags sentences opening with "There is/are" instead of naming the subject directly |
|
||||
| `assets/vale/styles/Kyberforge/VagueWording.yml` | Flags vague capability wording ("helps with", "utilize", "assists with", "used for") in descriptions |
|
||||
| `assets/vale/styles/KyberforgeCopilot/ProactivePhrase.yml` | Flags CC-specific "Use proactively" phrasing with no effect in Copilot descriptions |
|
||||
| `references/README.md` | Directory documentation for references/ |
|
||||
| `references/description-quality.md` | Qualitative guide for borderline description findings |
|
||||
| `references/field-inventory.md` | Authoritative list of valid CC and Copilot agent fields |
|
||||
@@ -32,7 +26,6 @@ Pass the path to either agent file as the argument.
|
||||
| `scripts/README.md` | Directory documentation for scripts/ |
|
||||
| `scripts/validate.sh` | Structural validation script for agent file pairs |
|
||||
| `scripts/validate-provenance.sh` | Provenance chain validation script for agent pairs against `sources.md` (plugin root) |
|
||||
| `scripts/vale-wrap.sh` | Drop-in `vale` wrapper that works around a frontmatter-description NLP scope limitation |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate.bats` | Bats tests for validate.sh |
|
||||
| `tests/validate-provenance.bats` | Bats tests for validate-provenance.sh |
|
||||
|
||||
@@ -36,13 +36,10 @@ metadata:
|
||||
```bash
|
||||
bash scripts/validate.sh <path-to-agent-file>
|
||||
bash scripts/validate-provenance.sh <path-to-agent-file>
|
||||
scripts/vale-wrap.sh <path-to-cc-file> <path-to-copilot-file>
|
||||
```
|
||||
|
||||
The script accepts either the CC file or the Copilot file. It detects provider from extension, derives the counterpart, and runs all structural checks. Note FAILs and SUGGESTIONs for the `### Structure` and `### Provider safety` report dimensions. Findings about missing fields, bad name format, empty body, or missing frontmatter → `### Structure`. Findings about CC-only fields in a Copilot file, Copilot-only fields in a CC file, plugin-silently-ignored fields, body length, or subagent-unavailable tools → `### Provider safety`. A missing counterpart file → `### Pair consistency`.
|
||||
|
||||
`vale-wrap.sh` ships inside this skill's own `scripts/` — resolve it relative to this skill's directory the same way `scripts/validate.sh` is resolved above, so the invocation works whether this skill is running from this repo or from an installed plugin cache. Pass no `--config`: handed none, the wrapper loads its own sibling `assets/vale/.vale.ini`, located from the script's path rather than from the cwd. Adding an explicit relative `--config` breaks exactly the case the self-location covers — a resolved script path plus an unresolved config path yields `E100 Runtime error ... does not exist`, exit 2, which the fallback below then misreads as "vale unavailable". Run it against both files of the pair (not just the one passed in). `Kyberforge` applies to both files; `KyberforgeCopilot` applies to the `.agent.md` file only, since its one rule (`Use proactively`) flags CC-specific phrasing that's meaningless in a Copilot description — there's nothing to flag in the CC file, so it isn't scoped there. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one in the `### Description` / `### Body` dimensions citing its rule ID (e.g. `KyberforgeCopilot.ProactivePhrase`). Skip and fall back to Step 2 judgment if the `vale` binary is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 2 judgment for the dimensions it would have covered.
|
||||
|
||||
`validate-provenance.sh` validates the provenance chain between the agent pair's `source_keys` and the plugin-scoped `sources.md` (plugin root — see ADR-0010). It exits 0 silently for non-plugin-scope agents and when no provenance data exists. Note FAILs from this script for the `### Provenance` dimension — surface them verbatim with Why and Fix.
|
||||
|
||||
If the scripts cannot run (Bash denied, python3 unavailable), perform checks manually: counterpart file exists, required fields present (`name`, `description`, non-empty body), `name` is kebab-case, Copilot CLI `.agent.md` `name` must match filename stem (CC files are exempt — the CC platform does not require name to match filename), no `FILL IN:` placeholders, no CC-only fields in Copilot file, no Copilot-only fields in CC file (read `references/field-inventory.md` for the authoritative field lists).
|
||||
@@ -52,17 +49,15 @@ If the scripts cannot run (Bash denied, python3 unavailable), perform checks man
|
||||
Read both agent files. Work through each dimension internally. Collect findings only; report in Step 3.
|
||||
|
||||
**Description (both files):**
|
||||
- Action-verb opening: description starts with a verb ("Reviews...", "Analyzes...", "Generates...") — FAIL if absent. Vale's `Kyberforge.DescriptionOpener` alert flags the specific known-bad "This agent..." opener directly; verifying an arbitrary opening word is genuinely a strong verb still requires judgment.
|
||||
- Specificity: is the trigger condition stated precisely? — SUGGESTION if vague. Vale's `Kyberforge.VagueWording` alert covers known filler ("helps with", "utilize", ...) directly; report those as FAILs without re-deriving by judgment.
|
||||
- `Use proactively` in a Copilot description: Vale's `KyberforgeCopilot.ProactivePhrase` alert (Copilot file only) flags this directly — report it without re-deriving by judgment.
|
||||
- Action-verb opening: description starts with a verb ("Reviews...", "Analyzes...", "Generates...") — FAIL if absent
|
||||
- Specificity: is the trigger condition stated precisely? — SUGGESTION if vague
|
||||
- `Use proactively` in a Copilot description: CC-specific phrasing, has no effect in Copilot — SUGGESTION to remove
|
||||
|
||||
If a description finding is borderline, read `references/description-quality.md`.
|
||||
|
||||
**Body:**
|
||||
- Direct role instruction: system prompt opens with `You are a [role]. When invoked, [action].` — SUGGESTION if absent
|
||||
- One job per agent: system prompt describes a single bounded task — SUGGESTION if scope appears unbounded
|
||||
- Generic, non-specific reference pointers to the `references/` directory: Vale's `Kyberforge.PaddingPhrase` alert flags this directly — report it without re-deriving by judgment
|
||||
- Sentences that open with "There is"/"There are": Vale's `Kyberforge.SentenceOpenerThereIs` alert flags this directly — report it without re-deriving by judgment
|
||||
|
||||
**Body/Frontmatter comments:**
|
||||
- Inspect each comment block in the YAML frontmatter. For each comment, apply: *"Would the agent get this wrong without this comment?"* Flag any that answer "no" as padding.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
StylesPath = styles
|
||||
|
||||
[**/agents/*.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
|
||||
[**/*.agent.md]
|
||||
BasedOnStyles = Kyberforge, KyberforgeCopilot
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Description opens with '%s' — use an imperative 'Use when...' opener instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
raw:
|
||||
- '^This (skill|agent)\b'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Generic reference pointer: '%s' — use the specific 'If X, read `references/file.md`' form instead"
|
||||
level: error
|
||||
scope: text
|
||||
ignorecase: true
|
||||
raw:
|
||||
- 'see references?/? for (more )?(info|information|details)\b'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Don't start a sentence with '%s' — name the subject directly"
|
||||
level: error
|
||||
scope: sentence
|
||||
ignorecase: false
|
||||
raw:
|
||||
- '^There\s(is|are)\b'
|
||||
@@ -1,10 +0,0 @@
|
||||
extends: existence
|
||||
message: "Vague capability wording: '%s' — state the capability precisely instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- helps with
|
||||
- utilize
|
||||
- assists with
|
||||
- used for
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "'%s' is CC-specific phrasing with no effect in Copilot descriptions — remove it"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- Use proactively
|
||||
@@ -1,526 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope
|
||||
# silently stops matching once the `description:` value spans 2+ physical lines
|
||||
# in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
|
||||
# scalar (the style used by most skills/agents in this repo), a plain scalar
|
||||
# wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
|
||||
# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
|
||||
# value keeps exactly the line breaks the source has, and vale matches it fine
|
||||
# (verified against vale 3.15.2), so literal blocks are deliberately left alone.
|
||||
# This script flattens an affected description to a one-line scalar in a scratch
|
||||
# copy — or, for the rare value no inline scalar can spell out verbatim, to a
|
||||
# `|-` literal block with a single content line, which vale matches just as well
|
||||
# (padding with blank lines so every other line number is unchanged), then
|
||||
# runs the real `vale` binary against the copies. Drop-in replacement for calling
|
||||
# `vale` directly: same args, same exit code, bar the two documented divergences
|
||||
# below.
|
||||
#
|
||||
# "Same args" means relative paths — path arguments and the values of the
|
||||
# path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
|
||||
# the caller's current directory, exactly as bare `vale` resolves them. The flag
|
||||
# values are rewritten to absolute form because the run ends up `cd`'d into the
|
||||
# scratch mirror, where a relative one would no longer resolve. (An earlier
|
||||
# version resolved path arguments against the repo root, an invented convention
|
||||
# that hard-errored on `--config ../../.vale.ini` from a subdirectory and, worse,
|
||||
# silently dropped file arguments that didn't happen to resolve from the repo
|
||||
# root — skipping the flattening this script exists for.)
|
||||
#
|
||||
# Divergence 1: with no `--config` at all, this script's own sibling
|
||||
# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
|
||||
# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
|
||||
# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
|
||||
# hard-fail (E100) for every external consumer. The manifest therefore passes the
|
||||
# script alone, and an explicit `--config` from any other caller still wins.
|
||||
#
|
||||
# Divergence 2: a path-shaped argument that does not exist is a hard error
|
||||
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
|
||||
# `0 errors ... in stdin` with exit 0 — a typo'd target is then indistinguishable
|
||||
# from a clean run. Both audit skills treat a `0 files` report as NOT RUN rather
|
||||
# than clean, and `in stdin` does not match that guard, so the silent form would
|
||||
# read as "prefilter clean" and skip the LLM fallback. Erroring is the only way
|
||||
# to keep that guard honest. Linting prose piped on stdin is therefore
|
||||
# unsupported here — it already was, since the no-path handoff closes stdin so
|
||||
# vale can't block on a pipe that will never carry content.
|
||||
#
|
||||
# Vale prints each path exactly as it was handed to it, so the scratch tree
|
||||
# mirrors the caller's absolute cwd: a relative path argument is passed through
|
||||
# verbatim and resolves to its flattened copy, keeping the report byte-identical
|
||||
# to bare `vale`'s. An absolute path inside the cwd is relativized to keep that
|
||||
# property. Only an absolute path outside the cwd is rewritten to its scratch
|
||||
# copy and so reports a scratch path — unavoidable, since a file can only be
|
||||
# read from where it actually is.
|
||||
|
||||
cwd="$(pwd -P)"
|
||||
|
||||
# Every array below is expanded as `${arr[@]+"${arr[@]}"}`: bash before 4.4 —
|
||||
# including the 3.2 that macOS still ships as /bin/bash — treats `"${arr[@]}"`
|
||||
# on an empty array as an unbound variable under `set -u`. No expansion site is
|
||||
# reachable while empty on today's control flow, so this is insurance against a
|
||||
# later edit breaking that invariant, not a live fix.
|
||||
vale_args=()
|
||||
path_args=()
|
||||
pending_flag=""
|
||||
config_given=false
|
||||
|
||||
# `--output` takes either one of vale's built-in style names or a template file
|
||||
# path. Only the file form needs absolutizing, and the built-in names have to be
|
||||
# excluded by name *before* the existence test below: a file or directory
|
||||
# literally called `line` in the caller's cwd would otherwise rewrite the
|
||||
# built-in into `$cwd/line`, flipping vale into template mode (`E100 [template]
|
||||
# Runtime error`) where bare vale just uses the built-in. `--path` has no such
|
||||
# names — it is always a path — so the check is keyed on the flag too.
|
||||
is_builtin_output() {
|
||||
case "$2" in
|
||||
line|JSON|CLI) [[ "$1" == "--output" ]] ;;
|
||||
*) false ;;
|
||||
esac
|
||||
}
|
||||
# Absolutizes a `--config` value against the caller's cwd. Shared by both
|
||||
# argument forms below — separated (`--config X`) and joined (`--config=X`)
|
||||
# — so the "already absolute vs. needs $cwd prefixed" check lives in exactly
|
||||
# one place instead of being duplicated per form.
|
||||
abs_config_value() {
|
||||
if [[ "$1" == /* ]]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
printf '%s' "$cwd/$1"
|
||||
fi
|
||||
}
|
||||
for arg in "$@"; do
|
||||
if [[ -n "$pending_flag" ]]; then
|
||||
# Value of a separated two-argv flag. It is never a lint target, however
|
||||
# file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
|
||||
# value naming a file has to be absolutized here or it stops resolving.
|
||||
case "$pending_flag" in
|
||||
--config)
|
||||
# Always a path, and required to exist.
|
||||
vale_args+=("$(abs_config_value "$arg")")
|
||||
;;
|
||||
--output|--path)
|
||||
# See `is_builtin_output` above for why the built-in `--output` names
|
||||
# are excluded first. Anything that names nothing is passed through and
|
||||
# left for vale to interpret.
|
||||
if is_builtin_output "$pending_flag" "$arg"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$arg" != /* && -e "$arg" ]]; then
|
||||
vale_args+=("$cwd/$arg")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
vale_args+=("$arg")
|
||||
;;
|
||||
esac
|
||||
pending_flag=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--config)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
--config=*)
|
||||
vale_args+=("--config=$(abs_config_value "${arg#--config=}")")
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
# Same cwd-relative resolution for the `--flag=value` spelling of the two
|
||||
# other path-valued flags.
|
||||
--output=*|--path=*)
|
||||
flag_val="${arg#*=}"
|
||||
if is_builtin_output "${arg%%=*}" "$flag_val"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
|
||||
vale_args+=("${arg%%=*}=$cwd/$flag_val")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
# Vale's remaining value-taking flags, per `vale --help` (3.x). In the
|
||||
# separated two-argv form the value must not be classified as a lint target
|
||||
# — `--output tmpl.tmpl` names a real template file, and treating it as
|
||||
# input both lints the template and reorders argv so vale sees
|
||||
# `--output --no-wrap`. The `--flag=value` form needs no entry here: it
|
||||
# starts with `-` and falls through to vale untouched. A value flag added by
|
||||
# some future vale release is simply absent from this list and lands back on
|
||||
# today's behaviour, so this list going stale is never worse than not having
|
||||
# it.
|
||||
--ext|--filter|--glob|--minAlertLevel|--output|--path)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
continue
|
||||
;;
|
||||
# Vale's subcommands are bare words that name no file, so they would trip
|
||||
# the not-found error below. A lint target literally named `sync` (no
|
||||
# extension, no slash) is misread as the subcommand — accepted, because the
|
||||
# alternative is failing every `vale-wrap.sh ls-config`.
|
||||
ls-config|ls-dirs|ls-metrics|ls-vars|sync)
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if [[ "$arg" == -* ]]; then
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
fi
|
||||
# Everything left is a lint target: `vale [options] [input...]` has no third
|
||||
# kind of argument. See divergence 2 above for why a missing one is fatal here.
|
||||
if [[ ! -e "$arg" ]]; then
|
||||
echo "vale-wrap.sh: no such file or directory: $arg" >&2
|
||||
exit 2
|
||||
fi
|
||||
# An absolute path inside the caller's cwd is relativized so the report cites
|
||||
# a path that resolves against the real tree. Left absolute, it would be
|
||||
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
|
||||
# path to a file that is deleted on exit, which reads as a bug in any report
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
path_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
path_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$config_given" == false ]]; then
|
||||
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
|
||||
fi
|
||||
|
||||
if [[ ${#path_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale ${vale_args[@]+"${vale_args[@]}"} < /dev/null
|
||||
fi
|
||||
|
||||
# `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing)
|
||||
# is a GNU extension the BSD realpath on macOS doesn't have — and every dest
|
||||
# below is a path that doesn't exist yet. python3 is already a hard dependency.
|
||||
abspath() {
|
||||
python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
|
||||
}
|
||||
|
||||
flatten() {
|
||||
# Two call shapes: `flatten src dest` (dest already resolved and inside the
|
||||
# scratch tree — the per-markdown-file calls in the directory branch below)
|
||||
# writes straight to `dest`. `flatten src raw_dest tmpdir` (the single-file
|
||||
# branch further down) additionally resolves `raw_dest` the way a separate
|
||||
# `abspath` call used to, applies the same sandbox-escape guard, and prints
|
||||
# the resolved path — folding two python3 spawns per file into one.
|
||||
python3 - "$@" <<'PYTHON'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, dest_input = sys.argv[1], sys.argv[2]
|
||||
tmpdir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
if tmpdir is None:
|
||||
dest = dest_input
|
||||
else:
|
||||
dest = os.path.abspath(dest_input)
|
||||
if not dest.startswith(tmpdir + os.sep):
|
||||
print(
|
||||
f"vale-wrap.sh: refusing to lint '{src}': its scratch copy would "
|
||||
f"land outside {tmpdir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
|
||||
# surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
|
||||
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
|
||||
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
content = fh.read()
|
||||
|
||||
# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\<newline>` is handled
|
||||
# separately in unescape_double because it also swallows the next indentation.
|
||||
DQ_ESCAPES = {
|
||||
'0': '\0', 'a': '\a', 'b': '\b', 't': '\t', '\t': '\t', 'n': '\n',
|
||||
'v': '\v', 'f': '\f', 'r': '\r', 'e': '\x1b', ' ': ' ', '"': '"',
|
||||
'/': '/', '\\': '\\', 'N': '\x85', '_': '\xa0', 'L': '\u2028',
|
||||
'P': '\u2029',
|
||||
}
|
||||
|
||||
# First characters that make a plain (unquoted) scalar mean something other than
|
||||
# text: YAML's c-indicator set.
|
||||
PLAIN_UNSAFE_FIRST = '-?:,[]{}#&*!|>\'"%@`'
|
||||
|
||||
|
||||
def unescape_double(text):
|
||||
"""Decode a double-quoted YAML scalar's body to the string YAML parses."""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if char != '\\':
|
||||
out.append(char)
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
if i >= len(text):
|
||||
break
|
||||
esc = text[i]
|
||||
if esc == '\n':
|
||||
i += 1
|
||||
while i < len(text) and text[i] in ' \t':
|
||||
i += 1
|
||||
continue
|
||||
if esc in 'xuU':
|
||||
width = {'x': 2, 'u': 4, 'U': 8}[esc]
|
||||
digits = text[i + 1:i + 1 + width]
|
||||
if len(digits) == width:
|
||||
try:
|
||||
out.append(chr(int(digits, 16)))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
i += 1 + width
|
||||
continue
|
||||
out.append(DQ_ESCAPES.get(esc, esc))
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def close_quote(text, quote):
|
||||
"""Index of the closing `quote` in `text`, which starts just past the
|
||||
opening one. None while the scalar is still unterminated."""
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if quote == '"' and char == '\\':
|
||||
i += 2
|
||||
continue
|
||||
if char == quote:
|
||||
if quote == "'" and text[i + 1:i + 2] == "'":
|
||||
i += 2
|
||||
continue
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def continuation_lines(rest):
|
||||
"""Yield the physical lines of `rest` that continue the value started on the
|
||||
`description:` line. Indentation-based and blank-line-tolerant, per YAML:
|
||||
a blank line (any amount of whitespace) always stays inside; the indent is
|
||||
set by the first content line; the value ends at the first line indented
|
||||
less than that, at any line flush with the key (that is the next mapping
|
||||
key, not a continuation), or at EOF."""
|
||||
indent = None
|
||||
for line in rest.splitlines(keepends=True):
|
||||
text = line.rstrip('\n')
|
||||
if text.strip() == '':
|
||||
yield line
|
||||
continue
|
||||
line_indent = len(text) - len(text.lstrip(' \t'))
|
||||
if line_indent == 0:
|
||||
return
|
||||
if indent is None:
|
||||
indent = line_indent
|
||||
elif line_indent < indent:
|
||||
return
|
||||
yield line
|
||||
|
||||
|
||||
def emit(value):
|
||||
"""Render `value` as a YAML scalar whose source text spells the value out
|
||||
verbatim. Vale locates the description by matching the parsed value back
|
||||
against the source, so a scalar carrying any escape — `''` in a
|
||||
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
|
||||
whole `text.frontmatter.description` scope vanish, the same failure this
|
||||
script exists to work around. Verbatim forms only, therefore, tried in
|
||||
descending order of fidelity. The first three occupy one physical line; the
|
||||
`|-` fallback occupies two, which the caller accounts for when padding."""
|
||||
if (value
|
||||
and value[0] not in PLAIN_UNSAFE_FIRST
|
||||
and ': ' not in value
|
||||
and not value.endswith(':')
|
||||
and ' #' not in value):
|
||||
return value # plain: nothing needs escaping at all
|
||||
if "'" not in value:
|
||||
return "'" + value + "'" # single-quoted: only `'` would escape
|
||||
if '"' not in value and '\\' not in value:
|
||||
return '"' + value + '"' # double-quoted: only `"`/`\` would
|
||||
# Last resort: the value needs quoting AND holds an apostrophe AND a double
|
||||
# quote or backslash, so no *inline* scalar can carry it verbatim. A `|-`
|
||||
# literal block can — a block scalar's body has no escape syntax at all, so
|
||||
# `'`, `"`, `\` and `: ` all survive byte for byte, and vale still matches
|
||||
# the description scope against it (the header above says the same of the
|
||||
# `|` blocks this script deliberately leaves alone; verified against vale
|
||||
# 3.15.2). One content line, indented two spaces, `-`-chomped so the parsed
|
||||
# value is exactly `value` with no trailing newline.
|
||||
return '|-\n ' + value
|
||||
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
if fm_match:
|
||||
fm = fm_match.group(2)
|
||||
header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
|
||||
else:
|
||||
header_m = None
|
||||
|
||||
if header_m:
|
||||
head_start = header_m.start()
|
||||
value_start = header_m.end()
|
||||
header_end = fm.find('\n', value_start)
|
||||
header_end = len(fm) if header_end == -1 else header_end
|
||||
first = fm[value_start:header_end]
|
||||
body_start = header_end + 1
|
||||
indicator = first.rstrip()
|
||||
|
||||
block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
|
||||
if block_m and block_m.group(1) == '|':
|
||||
kind = None # literal blocks keep their line breaks; vale is fine
|
||||
elif block_m:
|
||||
kind = 'block' # folded (`>`): the value starts on the next line
|
||||
elif indicator == '':
|
||||
kind = 'block' # bare `description:`: a plain scalar on later lines
|
||||
elif first[:1] == '"':
|
||||
kind = 'double'
|
||||
elif first[:1] == "'":
|
||||
kind = 'single'
|
||||
elif first[:1] in '#&*!':
|
||||
kind = None # comment, anchor, alias or tag — not a plain scalar
|
||||
else:
|
||||
kind = 'plain'
|
||||
|
||||
text = ''
|
||||
value_end = value_start
|
||||
value_lines = 0
|
||||
if kind in ('block', 'plain'):
|
||||
body = ''.join(continuation_lines(fm[body_start:]))
|
||||
value_end = body_start + len(body)
|
||||
if kind == 'block':
|
||||
text = body
|
||||
value_lines = body.count('\n')
|
||||
else:
|
||||
text = fm[value_start:value_end]
|
||||
value_lines = 1 + body.count('\n')
|
||||
if ' #' in text or text.lstrip().startswith('#'):
|
||||
# A `#` opens a comment inside a plain scalar. Folding it in
|
||||
# would lint text YAML never treats as part of the value, so
|
||||
# leave the file alone rather than lint the wrong string.
|
||||
kind = None
|
||||
elif kind in ('double', 'single'):
|
||||
quote = '"' if kind == 'double' else "'"
|
||||
inner_start = value_start + 1
|
||||
acc = fm[inner_start:body_start]
|
||||
idx = close_quote(acc, quote)
|
||||
lines = continuation_lines(fm[body_start:])
|
||||
while idx is None:
|
||||
try:
|
||||
acc += next(lines)
|
||||
except StopIteration:
|
||||
break
|
||||
idx = close_quote(acc, quote)
|
||||
if idx is None:
|
||||
kind = None # unterminated quote: invalid YAML, leave it to vale
|
||||
else:
|
||||
inner = acc[:idx]
|
||||
value_end = inner_start + idx + 1
|
||||
text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
|
||||
value_lines = 1 + inner.count('\n')
|
||||
|
||||
flat = re.sub(r'\s+', ' ', text).strip()
|
||||
if kind and flat and value_lines >= 2:
|
||||
# `value_end` can land mid-line, just past a closing quote, so extend to
|
||||
# the end of that physical line and carry whatever follows (a trailing
|
||||
# comment) across unchanged.
|
||||
if value_end > 0 and fm[value_end - 1] == '\n':
|
||||
span_end = value_end
|
||||
trailer = ''
|
||||
else:
|
||||
newline = fm.find('\n', value_end)
|
||||
span_end = len(fm) if newline == -1 else newline + 1
|
||||
trailer = fm[value_end:span_end].rstrip('\n')
|
||||
scalar = emit(flat)
|
||||
# A trailing comment carried across from the original line stays on the
|
||||
# `description:` line itself: after a block scalar's `|-` header it is
|
||||
# still a comment, but inside the block body it would become part of the
|
||||
# value.
|
||||
head, newline_sep, block_body = scalar.partition('\n')
|
||||
# The replacement displaces the whole span, so the blank-line pad makes
|
||||
# up the difference between the lines it displaced and the lines it
|
||||
# occupies — every later line number is unchanged. That is one line for
|
||||
# the three inline forms and two for the `|-` block; the span itself is
|
||||
# at least two lines here (`value_lines >= 2` is a precondition), so the
|
||||
# pad count never goes negative.
|
||||
pad = '\n' * (fm[head_start:span_end].count('\n') - 1 - scalar.count('\n'))
|
||||
new_fm = (fm[:head_start] + 'description: ' + head + trailer
|
||||
+ newline_sep + block_body + '\n' + pad + fm[span_end:])
|
||||
content = (fm_match.group(1) + new_fm + fm_match.group(3)
|
||||
+ content[fm_match.end():])
|
||||
|
||||
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(content)
|
||||
|
||||
if tmpdir is not None:
|
||||
print(dest)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
tmpdir="$(cd "$(mktemp -d)" && pwd -P)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative path arguments
|
||||
# are resolved from here.
|
||||
mirror="$tmpdir$cwd"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
argv_paths=()
|
||||
for arg in ${path_args[@]+"${path_args[@]}"}; do
|
||||
if [[ "$arg" == /* ]]; then
|
||||
raw_dest="$tmpdir$arg"
|
||||
else
|
||||
raw_dest="$mirror/$arg"
|
||||
fi
|
||||
if [[ -d "$arg" ]]; then
|
||||
dest="$(abspath "$raw_dest")"
|
||||
# A path argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
# A directory is mirrored whole — vale applies its own format filtering to
|
||||
# the tree, so any file dropped here would be silently unlinted — and then
|
||||
# every markdown file in the copy is flattened in place. `.git` is pruned:
|
||||
# vale never lints it and copying it can dwarf the rest of the tree.
|
||||
# `find -L` follows symlinks because vale does: it lints both a symlinked
|
||||
# file and a file under a symlinked directory, and a bare `-type f` walk
|
||||
# would report "0 files" where bare vale reports one. (A symlink loop makes
|
||||
# `find` warn on stderr and carry on, which is also what vale does.) The
|
||||
# second walk needs no `-L`: the mirror is all real files by construction.
|
||||
mkdir -p "$dest"
|
||||
while IFS= read -r -d '' rel; do
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
cp "$arg/$rel" "$dest/$rel"
|
||||
done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
|
||||
while IFS= read -r -d '' md; do
|
||||
flatten "$md" "$md"
|
||||
done < <(find "$dest" -type f -name '*.md' -print0)
|
||||
else
|
||||
# `abspath` + `flatten` folded into one python3 process — see the comment
|
||||
# atop `flatten` above.
|
||||
dest="$(flatten "$arg" "$raw_dest" "$tmpdir")"
|
||||
fi
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_paths+=("$dest")
|
||||
else
|
||||
argv_paths+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale ${vale_args[@]+"${vale_args[@]}"} ${argv_paths[@]+"${argv_paths[@]}"}
|
||||
@@ -123,7 +123,7 @@ source_keys:
|
||||
|
||||
### Step 3 — Fill in the Copilot agent file
|
||||
|
||||
**Two distinct Copilot agent formats** exist, with different paths and field sets. Choose one based on the deployment target:
|
||||
There are **two distinct Copilot agent formats** with different paths and field sets. Choose one based on the deployment target:
|
||||
|
||||
**CLI format** (default — what the scaffold creates):
|
||||
- Path: `.github/agents/<name>.agent.md` (project) or `<plugin>/agents/<name>.agent.md` (plugin)
|
||||
|
||||
@@ -4,7 +4,7 @@ Audit a skill directory against the agentskills.io specification. Runs structura
|
||||
|
||||
## What it does
|
||||
|
||||
1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags known-bad description openers, vague wording, padding phrases, and "There is/are" sentence openers
|
||||
1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks
|
||||
2. Reads all files in the skill directory
|
||||
3. Applies qualitative checks across seven dimensions
|
||||
4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to /skill-improve
|
||||
@@ -24,12 +24,6 @@ Provide the path to the skill directory to audit when invoking.
|
||||
| `SKILL.md` | Skill instructions for agents |
|
||||
| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description length, line count, 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/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/styles/Kyberforge/DescriptionOpener.yml` | Vale rule — flags literal "This skill..."/"This agent..." description openers |
|
||||
| `assets/vale/styles/Kyberforge/PaddingPhrase.yml` | Vale rule — flags generic "see references/" padding phrasing in conditional references |
|
||||
| `assets/vale/styles/Kyberforge/SentenceOpenerThereIs.yml` | Vale rule — flags body sentences starting with "There is"/"There are" |
|
||||
| `assets/vale/styles/Kyberforge/VagueWording.yml` | Vale rule — flags known filler wording (e.g. "helps with", "utilize") |
|
||||
| `references/description-quality.md` | Spec-grounded rubric for description auditing — loaded when a finding is borderline |
|
||||
| `references/body-discipline.md` | Spec-grounded rubric for body discipline auditing — loaded when padding vs necessity is unclear |
|
||||
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
||||
|
||||
@@ -34,15 +34,12 @@ metadata:
|
||||
```bash
|
||||
bash scripts/validate.sh <skill-dir>
|
||||
bash scripts/validate-provenance.sh <skill-dir>
|
||||
scripts/vale-wrap.sh <skill-dir>/SKILL.md
|
||||
```
|
||||
|
||||
Note any structural FAILs — they will appear in the report as a `### Structure` dimension. If the script cannot execute (python3 unavailable, Bash denied, or permission error), perform structural checks manually: name format, name matches directory, description length ≤1024 chars, SKILL.md ≤500 lines and ≤2770 words (the word count is a proxy for the ~5,000-token ceiling, and blocks a commit exactly like the line count does), no unfilled `FILL IN:` placeholders, scripts executable and free of interactive prompts.
|
||||
Note any structural FAILs — they will appear in the report as a `### Structure` dimension. If the script cannot execute (python3 unavailable, Bash denied, or permission error), perform structural checks manually: name format, name matches directory, description length ≤1024 chars, SKILL.md ≤500 lines, no unfilled `FILL IN:` placeholders, scripts executable and free of interactive prompts.
|
||||
|
||||
Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim.
|
||||
|
||||
`vale-wrap.sh` ships inside this skill's own `scripts/` — resolve it relative to this skill's directory the same way `scripts/validate.sh` is resolved above, so the invocation works whether this skill is running from this repo or from an installed plugin cache. Pass no `--config`: handed none, the wrapper loads its own sibling `assets/vale/.vale.ini`, located from the script's path rather than from the cwd. Adding an explicit relative `--config` breaks exactly the case the self-location covers — a resolved script path plus an unresolved config path yields `E100 Runtime error ... does not exist`, exit 2, which the fallback below then misreads as "vale unavailable". It applies that config's `Kyberforge` style — a deterministic prefilter for a subset of the Description/Patterns/Body dimensions below, not a replacement for Step 3. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one citing its rule ID (e.g. `Kyberforge.DescriptionOpener`). Skip and fall back to Step 3 judgment if the `vale` binary is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 3 judgment for the dimensions it would have covered.
|
||||
|
||||
## Step 2 — Read all skill files
|
||||
|
||||
Read every file in the skill directory: `SKILL.md`, `README.md` (if present), all files in `scripts/`, `references/`, `assets/`, and `tests/`. Skip binary files only. Do not skip text files — internal consistency checks require the full picture.
|
||||
@@ -53,10 +50,8 @@ Work through each dimension internally. Collect findings only; report them in St
|
||||
|
||||
### Description
|
||||
|
||||
Vale's `Kyberforge.DescriptionOpener` ("This skill..." openers) and `Kyberforge.VagueWording` (filler like "helps with", "utilize") alerts from Step 1 — both FAILs — cover imperative phrasing and known vague-wording filler directly; report them as findings without re-deriving by judgment. The rest is still a judgment call:
|
||||
|
||||
- **Action-verb opening**: does the description start with a verb ("Audits...", "Reviews...", "Validates...")? Vale's `Kyberforge.DescriptionOpener` alert only catches the literal "This skill..." pattern — confirming an arbitrary opening word is genuinely a strong verb still requires judgment.
|
||||
- **Specificity beyond the filler blocklist**: are capabilities stated precisely ("parses OpenAPI specs") or genuinely vaguely ("handles files")?
|
||||
- **Imperative phrasing**: does it use "Use when..." not "This skill..."?
|
||||
- **Specificity**: are capabilities stated precisely ("parses OpenAPI specs") or vaguely ("helps with APIs")?
|
||||
- **Indirect triggers**: does it cover cases where the user doesn't name the domain directly?
|
||||
- **Near-miss exclusions**: are "Do not use when..." clauses present if a near-miss skill could steal activations?
|
||||
- **Length**: under 1024 characters?
|
||||
@@ -71,8 +66,6 @@ For each sentence in the body, apply: *"Would the agent get this wrong without t
|
||||
- **Why rationale**: include/exclude rules explain why, not just what
|
||||
- **Control calibration**: prescriptive for fragile or critical sequences (e.g. a script invocation where flag order or exact arguments must not change); flexible where multiple approaches are valid
|
||||
|
||||
Vale's `Kyberforge.SentenceOpenerThereIs` alert from Step 1 (FAIL — sentences starting with "There is"/"There are") covers pattern-matchable body-wide filler directly; report it as a finding without re-deriving by judgment.
|
||||
|
||||
If uncertain whether a sentence is padding or whether a control decision is correctly calibrated, read `references/body-discipline.md`.
|
||||
|
||||
### Patterns
|
||||
@@ -82,7 +75,7 @@ Check each pattern is appropriate and correctly formed:
|
||||
- **Gotchas**: placed near the top; each entry is a specific fact that defies a reasonable assumption — not a general tip
|
||||
- **Prescriptive sequence**: inner code fences escaped as `\`\`\`` when nested inside a markdown block
|
||||
- **Checklists**: used for multi-step workflows, not single steps
|
||||
- **Conditional references**: specific trigger stated ("If X, read `references/file.md`") — not a generic "see references/". Vale's `Kyberforge.PaddingPhrase` alert from Step 1 flags the generic phrasing directly; other malformed conditional-reference forms still require judgment.
|
||||
- **Conditional references**: specific trigger stated ("If X, read `references/file.md`") — not a generic "see references/"
|
||||
- **Output templates**: present when the agent must produce a specific format; absent otherwise
|
||||
|
||||
### File structure
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
StylesPath = styles
|
||||
|
||||
[**/SKILL.md]
|
||||
BasedOnStyles = Kyberforge
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Description opens with '%s' — use an imperative 'Use when...' opener instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
raw:
|
||||
- '^This (skill|agent)\b'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Generic reference pointer: '%s' — use the specific 'If X, read `references/file.md`' form instead"
|
||||
level: error
|
||||
scope: text
|
||||
ignorecase: true
|
||||
raw:
|
||||
- 'see references?/? for (more )?(info|information|details)\b'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Don't start a sentence with '%s' — name the subject directly"
|
||||
level: error
|
||||
scope: sentence
|
||||
ignorecase: false
|
||||
raw:
|
||||
- '^There\s(is|are)\b'
|
||||
@@ -1,10 +0,0 @@
|
||||
extends: existence
|
||||
message: "Vague capability wording: '%s' — state the capability precisely instead"
|
||||
level: error
|
||||
scope: text.frontmatter.description
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- helps with
|
||||
- utilize
|
||||
- assists with
|
||||
- used for
|
||||
@@ -1,526 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope
|
||||
# silently stops matching once the `description:` value spans 2+ physical lines
|
||||
# in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
|
||||
# scalar (the style used by most skills/agents in this repo), a plain scalar
|
||||
# wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
|
||||
# the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
|
||||
# value keeps exactly the line breaks the source has, and vale matches it fine
|
||||
# (verified against vale 3.15.2), so literal blocks are deliberately left alone.
|
||||
# This script flattens an affected description to a one-line scalar in a scratch
|
||||
# copy — or, for the rare value no inline scalar can spell out verbatim, to a
|
||||
# `|-` literal block with a single content line, which vale matches just as well
|
||||
# (padding with blank lines so every other line number is unchanged), then
|
||||
# runs the real `vale` binary against the copies. Drop-in replacement for calling
|
||||
# `vale` directly: same args, same exit code, bar the two documented divergences
|
||||
# below.
|
||||
#
|
||||
# "Same args" means relative paths — path arguments and the values of the
|
||||
# path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
|
||||
# the caller's current directory, exactly as bare `vale` resolves them. The flag
|
||||
# values are rewritten to absolute form because the run ends up `cd`'d into the
|
||||
# scratch mirror, where a relative one would no longer resolve. (An earlier
|
||||
# version resolved path arguments against the repo root, an invented convention
|
||||
# that hard-errored on `--config ../../.vale.ini` from a subdirectory and, worse,
|
||||
# silently dropped file arguments that didn't happen to resolve from the repo
|
||||
# root — skipping the flattening this script exists for.)
|
||||
#
|
||||
# Divergence 1: with no `--config` at all, this script's own sibling
|
||||
# `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
|
||||
# prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
|
||||
# `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
|
||||
# hard-fail (E100) for every external consumer. The manifest therefore passes the
|
||||
# script alone, and an explicit `--config` from any other caller still wins.
|
||||
#
|
||||
# Divergence 2: a path-shaped argument that does not exist is a hard error
|
||||
# (exit 2). Bare vale drops it, falls back to reading stdin, and prints
|
||||
# `0 errors ... in stdin` with exit 0 — a typo'd target is then indistinguishable
|
||||
# from a clean run. Both audit skills treat a `0 files` report as NOT RUN rather
|
||||
# than clean, and `in stdin` does not match that guard, so the silent form would
|
||||
# read as "prefilter clean" and skip the LLM fallback. Erroring is the only way
|
||||
# to keep that guard honest. Linting prose piped on stdin is therefore
|
||||
# unsupported here — it already was, since the no-path handoff closes stdin so
|
||||
# vale can't block on a pipe that will never carry content.
|
||||
#
|
||||
# Vale prints each path exactly as it was handed to it, so the scratch tree
|
||||
# mirrors the caller's absolute cwd: a relative path argument is passed through
|
||||
# verbatim and resolves to its flattened copy, keeping the report byte-identical
|
||||
# to bare `vale`'s. An absolute path inside the cwd is relativized to keep that
|
||||
# property. Only an absolute path outside the cwd is rewritten to its scratch
|
||||
# copy and so reports a scratch path — unavoidable, since a file can only be
|
||||
# read from where it actually is.
|
||||
|
||||
cwd="$(pwd -P)"
|
||||
|
||||
# Every array below is expanded as `${arr[@]+"${arr[@]}"}`: bash before 4.4 —
|
||||
# including the 3.2 that macOS still ships as /bin/bash — treats `"${arr[@]}"`
|
||||
# on an empty array as an unbound variable under `set -u`. No expansion site is
|
||||
# reachable while empty on today's control flow, so this is insurance against a
|
||||
# later edit breaking that invariant, not a live fix.
|
||||
vale_args=()
|
||||
path_args=()
|
||||
pending_flag=""
|
||||
config_given=false
|
||||
|
||||
# `--output` takes either one of vale's built-in style names or a template file
|
||||
# path. Only the file form needs absolutizing, and the built-in names have to be
|
||||
# excluded by name *before* the existence test below: a file or directory
|
||||
# literally called `line` in the caller's cwd would otherwise rewrite the
|
||||
# built-in into `$cwd/line`, flipping vale into template mode (`E100 [template]
|
||||
# Runtime error`) where bare vale just uses the built-in. `--path` has no such
|
||||
# names — it is always a path — so the check is keyed on the flag too.
|
||||
is_builtin_output() {
|
||||
case "$2" in
|
||||
line|JSON|CLI) [[ "$1" == "--output" ]] ;;
|
||||
*) false ;;
|
||||
esac
|
||||
}
|
||||
# Absolutizes a `--config` value against the caller's cwd. Shared by both
|
||||
# argument forms below — separated (`--config X`) and joined (`--config=X`)
|
||||
# — so the "already absolute vs. needs $cwd prefixed" check lives in exactly
|
||||
# one place instead of being duplicated per form.
|
||||
abs_config_value() {
|
||||
if [[ "$1" == /* ]]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
printf '%s' "$cwd/$1"
|
||||
fi
|
||||
}
|
||||
for arg in "$@"; do
|
||||
if [[ -n "$pending_flag" ]]; then
|
||||
# Value of a separated two-argv flag. It is never a lint target, however
|
||||
# file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
|
||||
# value naming a file has to be absolutized here or it stops resolving.
|
||||
case "$pending_flag" in
|
||||
--config)
|
||||
# Always a path, and required to exist.
|
||||
vale_args+=("$(abs_config_value "$arg")")
|
||||
;;
|
||||
--output|--path)
|
||||
# See `is_builtin_output` above for why the built-in `--output` names
|
||||
# are excluded first. Anything that names nothing is passed through and
|
||||
# left for vale to interpret.
|
||||
if is_builtin_output "$pending_flag" "$arg"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$arg" != /* && -e "$arg" ]]; then
|
||||
vale_args+=("$cwd/$arg")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
vale_args+=("$arg")
|
||||
;;
|
||||
esac
|
||||
pending_flag=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--config)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
--config=*)
|
||||
vale_args+=("--config=$(abs_config_value "${arg#--config=}")")
|
||||
config_given=true
|
||||
continue
|
||||
;;
|
||||
# Same cwd-relative resolution for the `--flag=value` spelling of the two
|
||||
# other path-valued flags.
|
||||
--output=*|--path=*)
|
||||
flag_val="${arg#*=}"
|
||||
if is_builtin_output "${arg%%=*}" "$flag_val"; then
|
||||
vale_args+=("$arg")
|
||||
elif [[ "$flag_val" != /* && -n "$flag_val" && -e "$flag_val" ]]; then
|
||||
vale_args+=("${arg%%=*}=$cwd/$flag_val")
|
||||
else
|
||||
vale_args+=("$arg")
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
# Vale's remaining value-taking flags, per `vale --help` (3.x). In the
|
||||
# separated two-argv form the value must not be classified as a lint target
|
||||
# — `--output tmpl.tmpl` names a real template file, and treating it as
|
||||
# input both lints the template and reorders argv so vale sees
|
||||
# `--output --no-wrap`. The `--flag=value` form needs no entry here: it
|
||||
# starts with `-` and falls through to vale untouched. A value flag added by
|
||||
# some future vale release is simply absent from this list and lands back on
|
||||
# today's behaviour, so this list going stale is never worse than not having
|
||||
# it.
|
||||
--ext|--filter|--glob|--minAlertLevel|--output|--path)
|
||||
vale_args+=("$arg")
|
||||
pending_flag="$arg"
|
||||
continue
|
||||
;;
|
||||
# Vale's subcommands are bare words that name no file, so they would trip
|
||||
# the not-found error below. A lint target literally named `sync` (no
|
||||
# extension, no slash) is misread as the subcommand — accepted, because the
|
||||
# alternative is failing every `vale-wrap.sh ls-config`.
|
||||
ls-config|ls-dirs|ls-metrics|ls-vars|sync)
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if [[ "$arg" == -* ]]; then
|
||||
vale_args+=("$arg")
|
||||
continue
|
||||
fi
|
||||
# Everything left is a lint target: `vale [options] [input...]` has no third
|
||||
# kind of argument. See divergence 2 above for why a missing one is fatal here.
|
||||
if [[ ! -e "$arg" ]]; then
|
||||
echo "vale-wrap.sh: no such file or directory: $arg" >&2
|
||||
exit 2
|
||||
fi
|
||||
# An absolute path inside the caller's cwd is relativized so the report cites
|
||||
# a path that resolves against the real tree. Left absolute, it would be
|
||||
# rewritten to its scratch copy and printed as `/tmp/tmp.XXXX/...` — a real
|
||||
# path to a file that is deleted on exit, which reads as a bug in any report
|
||||
# quoting it. Absolute paths outside the cwd have no relative form and keep
|
||||
# the scratch-path behaviour documented above.
|
||||
if [[ "$arg" == "$cwd"/* ]]; then
|
||||
path_args+=("${arg#"$cwd"/}")
|
||||
else
|
||||
path_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$config_given" == false ]]; then
|
||||
vale_args+=(--config "$(cd "$(dirname "${BASH_SOURCE[0]}")/../assets/vale" && pwd)/.vale.ini")
|
||||
fi
|
||||
|
||||
if [[ ${#path_args[@]} -eq 0 ]]; then
|
||||
# Nothing to flatten. Hand off directly, with stdin closed so vale doesn't
|
||||
# block waiting on a pipe that will never carry content.
|
||||
exec vale ${vale_args[@]+"${vale_args[@]}"} < /dev/null
|
||||
fi
|
||||
|
||||
# `realpath -m` would be the obvious normalizer, but `-m` (canonicalize-missing)
|
||||
# is a GNU extension the BSD realpath on macOS doesn't have — and every dest
|
||||
# below is a path that doesn't exist yet. python3 is already a hard dependency.
|
||||
abspath() {
|
||||
python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "$1"
|
||||
}
|
||||
|
||||
flatten() {
|
||||
# Two call shapes: `flatten src dest` (dest already resolved and inside the
|
||||
# scratch tree — the per-markdown-file calls in the directory branch below)
|
||||
# writes straight to `dest`. `flatten src raw_dest tmpdir` (the single-file
|
||||
# branch further down) additionally resolves `raw_dest` the way a separate
|
||||
# `abspath` call used to, applies the same sandbox-escape guard, and prints
|
||||
# the resolved path — folding two python3 spawns per file into one.
|
||||
python3 - "$@" <<'PYTHON'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, dest_input = sys.argv[1], sys.argv[2]
|
||||
tmpdir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
if tmpdir is None:
|
||||
dest = dest_input
|
||||
else:
|
||||
dest = os.path.abspath(dest_input)
|
||||
if not dest.startswith(tmpdir + os.sep):
|
||||
print(
|
||||
f"vale-wrap.sh: refusing to lint '{src}': its scratch copy would "
|
||||
f"land outside {tmpdir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
|
||||
# surrogateescape keeps a non-UTF-8 file (reachable via a directory argument)
|
||||
# a byte-for-byte round trip instead of aborting the whole run on a decode error.
|
||||
with open(src, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
content = fh.read()
|
||||
|
||||
# YAML 1.2 double-quoted escapes (spec 5.7 / 7.3.1). `\<newline>` is handled
|
||||
# separately in unescape_double because it also swallows the next indentation.
|
||||
DQ_ESCAPES = {
|
||||
'0': '\0', 'a': '\a', 'b': '\b', 't': '\t', '\t': '\t', 'n': '\n',
|
||||
'v': '\v', 'f': '\f', 'r': '\r', 'e': '\x1b', ' ': ' ', '"': '"',
|
||||
'/': '/', '\\': '\\', 'N': '\x85', '_': '\xa0', 'L': '\u2028',
|
||||
'P': '\u2029',
|
||||
}
|
||||
|
||||
# First characters that make a plain (unquoted) scalar mean something other than
|
||||
# text: YAML's c-indicator set.
|
||||
PLAIN_UNSAFE_FIRST = '-?:,[]{}#&*!|>\'"%@`'
|
||||
|
||||
|
||||
def unescape_double(text):
|
||||
"""Decode a double-quoted YAML scalar's body to the string YAML parses."""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if char != '\\':
|
||||
out.append(char)
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
if i >= len(text):
|
||||
break
|
||||
esc = text[i]
|
||||
if esc == '\n':
|
||||
i += 1
|
||||
while i < len(text) and text[i] in ' \t':
|
||||
i += 1
|
||||
continue
|
||||
if esc in 'xuU':
|
||||
width = {'x': 2, 'u': 4, 'U': 8}[esc]
|
||||
digits = text[i + 1:i + 1 + width]
|
||||
if len(digits) == width:
|
||||
try:
|
||||
out.append(chr(int(digits, 16)))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
i += 1 + width
|
||||
continue
|
||||
out.append(DQ_ESCAPES.get(esc, esc))
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def close_quote(text, quote):
|
||||
"""Index of the closing `quote` in `text`, which starts just past the
|
||||
opening one. None while the scalar is still unterminated."""
|
||||
i = 0
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
if quote == '"' and char == '\\':
|
||||
i += 2
|
||||
continue
|
||||
if char == quote:
|
||||
if quote == "'" and text[i + 1:i + 2] == "'":
|
||||
i += 2
|
||||
continue
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def continuation_lines(rest):
|
||||
"""Yield the physical lines of `rest` that continue the value started on the
|
||||
`description:` line. Indentation-based and blank-line-tolerant, per YAML:
|
||||
a blank line (any amount of whitespace) always stays inside; the indent is
|
||||
set by the first content line; the value ends at the first line indented
|
||||
less than that, at any line flush with the key (that is the next mapping
|
||||
key, not a continuation), or at EOF."""
|
||||
indent = None
|
||||
for line in rest.splitlines(keepends=True):
|
||||
text = line.rstrip('\n')
|
||||
if text.strip() == '':
|
||||
yield line
|
||||
continue
|
||||
line_indent = len(text) - len(text.lstrip(' \t'))
|
||||
if line_indent == 0:
|
||||
return
|
||||
if indent is None:
|
||||
indent = line_indent
|
||||
elif line_indent < indent:
|
||||
return
|
||||
yield line
|
||||
|
||||
|
||||
def emit(value):
|
||||
"""Render `value` as a YAML scalar whose source text spells the value out
|
||||
verbatim. Vale locates the description by matching the parsed value back
|
||||
against the source, so a scalar carrying any escape — `''` in a
|
||||
single-quoted scalar, `\\"` or `\\\\` in a double-quoted one — makes the
|
||||
whole `text.frontmatter.description` scope vanish, the same failure this
|
||||
script exists to work around. Verbatim forms only, therefore, tried in
|
||||
descending order of fidelity. The first three occupy one physical line; the
|
||||
`|-` fallback occupies two, which the caller accounts for when padding."""
|
||||
if (value
|
||||
and value[0] not in PLAIN_UNSAFE_FIRST
|
||||
and ': ' not in value
|
||||
and not value.endswith(':')
|
||||
and ' #' not in value):
|
||||
return value # plain: nothing needs escaping at all
|
||||
if "'" not in value:
|
||||
return "'" + value + "'" # single-quoted: only `'` would escape
|
||||
if '"' not in value and '\\' not in value:
|
||||
return '"' + value + '"' # double-quoted: only `"`/`\` would
|
||||
# Last resort: the value needs quoting AND holds an apostrophe AND a double
|
||||
# quote or backslash, so no *inline* scalar can carry it verbatim. A `|-`
|
||||
# literal block can — a block scalar's body has no escape syntax at all, so
|
||||
# `'`, `"`, `\` and `: ` all survive byte for byte, and vale still matches
|
||||
# the description scope against it (the header above says the same of the
|
||||
# `|` blocks this script deliberately leaves alone; verified against vale
|
||||
# 3.15.2). One content line, indented two spaces, `-`-chomped so the parsed
|
||||
# value is exactly `value` with no trailing newline.
|
||||
return '|-\n ' + value
|
||||
|
||||
|
||||
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
|
||||
if fm_match:
|
||||
fm = fm_match.group(2)
|
||||
header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
|
||||
else:
|
||||
header_m = None
|
||||
|
||||
if header_m:
|
||||
head_start = header_m.start()
|
||||
value_start = header_m.end()
|
||||
header_end = fm.find('\n', value_start)
|
||||
header_end = len(fm) if header_end == -1 else header_end
|
||||
first = fm[value_start:header_end]
|
||||
body_start = header_end + 1
|
||||
indicator = first.rstrip()
|
||||
|
||||
block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
|
||||
if block_m and block_m.group(1) == '|':
|
||||
kind = None # literal blocks keep their line breaks; vale is fine
|
||||
elif block_m:
|
||||
kind = 'block' # folded (`>`): the value starts on the next line
|
||||
elif indicator == '':
|
||||
kind = 'block' # bare `description:`: a plain scalar on later lines
|
||||
elif first[:1] == '"':
|
||||
kind = 'double'
|
||||
elif first[:1] == "'":
|
||||
kind = 'single'
|
||||
elif first[:1] in '#&*!':
|
||||
kind = None # comment, anchor, alias or tag — not a plain scalar
|
||||
else:
|
||||
kind = 'plain'
|
||||
|
||||
text = ''
|
||||
value_end = value_start
|
||||
value_lines = 0
|
||||
if kind in ('block', 'plain'):
|
||||
body = ''.join(continuation_lines(fm[body_start:]))
|
||||
value_end = body_start + len(body)
|
||||
if kind == 'block':
|
||||
text = body
|
||||
value_lines = body.count('\n')
|
||||
else:
|
||||
text = fm[value_start:value_end]
|
||||
value_lines = 1 + body.count('\n')
|
||||
if ' #' in text or text.lstrip().startswith('#'):
|
||||
# A `#` opens a comment inside a plain scalar. Folding it in
|
||||
# would lint text YAML never treats as part of the value, so
|
||||
# leave the file alone rather than lint the wrong string.
|
||||
kind = None
|
||||
elif kind in ('double', 'single'):
|
||||
quote = '"' if kind == 'double' else "'"
|
||||
inner_start = value_start + 1
|
||||
acc = fm[inner_start:body_start]
|
||||
idx = close_quote(acc, quote)
|
||||
lines = continuation_lines(fm[body_start:])
|
||||
while idx is None:
|
||||
try:
|
||||
acc += next(lines)
|
||||
except StopIteration:
|
||||
break
|
||||
idx = close_quote(acc, quote)
|
||||
if idx is None:
|
||||
kind = None # unterminated quote: invalid YAML, leave it to vale
|
||||
else:
|
||||
inner = acc[:idx]
|
||||
value_end = inner_start + idx + 1
|
||||
text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
|
||||
value_lines = 1 + inner.count('\n')
|
||||
|
||||
flat = re.sub(r'\s+', ' ', text).strip()
|
||||
if kind and flat and value_lines >= 2:
|
||||
# `value_end` can land mid-line, just past a closing quote, so extend to
|
||||
# the end of that physical line and carry whatever follows (a trailing
|
||||
# comment) across unchanged.
|
||||
if value_end > 0 and fm[value_end - 1] == '\n':
|
||||
span_end = value_end
|
||||
trailer = ''
|
||||
else:
|
||||
newline = fm.find('\n', value_end)
|
||||
span_end = len(fm) if newline == -1 else newline + 1
|
||||
trailer = fm[value_end:span_end].rstrip('\n')
|
||||
scalar = emit(flat)
|
||||
# A trailing comment carried across from the original line stays on the
|
||||
# `description:` line itself: after a block scalar's `|-` header it is
|
||||
# still a comment, but inside the block body it would become part of the
|
||||
# value.
|
||||
head, newline_sep, block_body = scalar.partition('\n')
|
||||
# The replacement displaces the whole span, so the blank-line pad makes
|
||||
# up the difference between the lines it displaced and the lines it
|
||||
# occupies — every later line number is unchanged. That is one line for
|
||||
# the three inline forms and two for the `|-` block; the span itself is
|
||||
# at least two lines here (`value_lines >= 2` is a precondition), so the
|
||||
# pad count never goes negative.
|
||||
pad = '\n' * (fm[head_start:span_end].count('\n') - 1 - scalar.count('\n'))
|
||||
new_fm = (fm[:head_start] + 'description: ' + head + trailer
|
||||
+ newline_sep + block_body + '\n' + pad + fm[span_end:])
|
||||
content = (fm_match.group(1) + new_fm + fm_match.group(3)
|
||||
+ content[fm_match.end():])
|
||||
|
||||
with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(content)
|
||||
|
||||
if tmpdir is not None:
|
||||
print(dest)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
tmpdir="$(cd "$(mktemp -d)" && pwd -P)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# Mirror of the caller's cwd inside the scratch tree; relative path arguments
|
||||
# are resolved from here.
|
||||
mirror="$tmpdir$cwd"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
argv_paths=()
|
||||
for arg in ${path_args[@]+"${path_args[@]}"}; do
|
||||
if [[ "$arg" == /* ]]; then
|
||||
raw_dest="$tmpdir$arg"
|
||||
else
|
||||
raw_dest="$mirror/$arg"
|
||||
fi
|
||||
if [[ -d "$arg" ]]; then
|
||||
dest="$(abspath "$raw_dest")"
|
||||
# A path argument with enough leading `..` to climb past the mirror root would
|
||||
# write outside the scratch dir. The real filesystem clamps such a path at
|
||||
# `/`; the mirror can't, so refuse rather than scribble outside the sandbox.
|
||||
case "$dest" in
|
||||
"$tmpdir"/*) ;;
|
||||
*)
|
||||
echo "vale-wrap.sh: refusing to lint '$arg': its scratch copy would land outside $tmpdir" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
# A directory is mirrored whole — vale applies its own format filtering to
|
||||
# the tree, so any file dropped here would be silently unlinted — and then
|
||||
# every markdown file in the copy is flattened in place. `.git` is pruned:
|
||||
# vale never lints it and copying it can dwarf the rest of the tree.
|
||||
# `find -L` follows symlinks because vale does: it lints both a symlinked
|
||||
# file and a file under a symlinked directory, and a bare `-type f` walk
|
||||
# would report "0 files" where bare vale reports one. (A symlink loop makes
|
||||
# `find` warn on stderr and carry on, which is also what vale does.) The
|
||||
# second walk needs no `-L`: the mirror is all real files by construction.
|
||||
mkdir -p "$dest"
|
||||
while IFS= read -r -d '' rel; do
|
||||
mkdir -p "$dest/$(dirname "$rel")"
|
||||
cp "$arg/$rel" "$dest/$rel"
|
||||
done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
|
||||
while IFS= read -r -d '' md; do
|
||||
flatten "$md" "$md"
|
||||
done < <(find "$dest" -type f -name '*.md' -print0)
|
||||
else
|
||||
# `abspath` + `flatten` folded into one python3 process — see the comment
|
||||
# atop `flatten` above.
|
||||
dest="$(flatten "$arg" "$raw_dest" "$tmpdir")"
|
||||
fi
|
||||
if [[ "$arg" == /* ]]; then
|
||||
argv_paths+=("$dest")
|
||||
else
|
||||
argv_paths+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$mirror"
|
||||
vale ${vale_args[@]+"${vale_args[@]}"} ${argv_paths[@]+"${argv_paths[@]}"}
|
||||
@@ -133,32 +133,12 @@ else:
|
||||
if desc:
|
||||
ok("description has no unfilled placeholders")
|
||||
|
||||
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
||||
# ~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
|
||||
# outside the plugin directory once the plugin is cache-installed, so there is
|
||||
# no single source to share. Keep the two in sync by hand: if they drift, this
|
||||
# audit will report a skill ready to ship that the commit hook then rejects.
|
||||
MAX_LINES = 500
|
||||
# Word-count proxy for the ~5,000-token ceiling, calibrated to the densest
|
||||
# prose in the corpus (7.22 chars/word): 2770 words is ~20,000 characters,
|
||||
# ~5,000 tokens at 4 characters per token. See skill-size-check.sh's header
|
||||
# for the full measurement.
|
||||
MAX_WORDS = 2770
|
||||
|
||||
# SKILL.md line count
|
||||
line_count = len(content.splitlines())
|
||||
if line_count <= MAX_LINES:
|
||||
ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
|
||||
if line_count <= 500:
|
||||
ok(f"SKILL.md line count {line_count} (limit: 500)")
|
||||
else:
|
||||
fail(f"SKILL.md line count {line_count} — exceeds {MAX_LINES}-line limit")
|
||||
|
||||
# str.split() with no argument splits on runs of whitespace, matching the
|
||||
# `wc -w` the hook uses, and counts the whole file including frontmatter.
|
||||
word_count = len(content.split())
|
||||
if word_count <= MAX_WORDS:
|
||||
ok(f"SKILL.md word count {word_count} (limit: {MAX_WORDS}, proxy for ~5,000 tokens)")
|
||||
else:
|
||||
fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)")
|
||||
fail(f"SKILL.md line count {line_count} — exceeds 500-line limit")
|
||||
|
||||
# Body unfilled placeholders
|
||||
body = content[body_start:]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
"url": "https://git.dev.rkdr.net/Defame1297/"
|
||||
},
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"displayName": "Lint",
|
||||
"keywords": [
|
||||
"lint",
|
||||
"style",
|
||||
"prose",
|
||||
"linter"
|
||||
],
|
||||
"license": "MIT",
|
||||
"name": "lint",
|
||||
"version": "1.1.5"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: lint-runner
|
||||
|
||||
description: Runs a linter sweep over a target file or directory scope and reports findings. Currently backs onto Vale (prose/style linting) via the vale-config and vale-run skills; built to add other linters later without changing its own contract. Use when a caller needs a lint pass run in an isolated context and wants findings back, not fixes applied.
|
||||
|
||||
tools: ["execute", "read", "search"]
|
||||
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
|
||||
---
|
||||
|
||||
You are a linter runner. When invoked, you run the appropriate linter(s) over the requested scope, collect their findings, and report them back in a structured, reviewable form. You never edit files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **scope:** file path, directory path, or glob to lint
|
||||
- **linter:** which linter to run (defaults to `vale` — the only backend currently wired up)
|
||||
- **config context:** any project-specific linter configuration already in place (e.g. an existing `.vale.ini` for Vale, or whatever config format the requested linter expects); if none exists, say so in your report rather than inventing one
|
||||
|
||||
## Process
|
||||
|
||||
1. Determine whether the target scope already has configuration in place for the requested `linter` (whatever config format that linter expects). If not, use the `<linter>-config` skill (e.g. `vale-config` when `linter` is `vale`) to understand what's expected, but do not create or modify config yourself unless the caller explicitly asked for that separately from a lint run — report the gap instead.
|
||||
2. Use the `<linter>-run` skill (e.g. `vale-run` when `linter` is `vale`) to invoke the linter over the scope and interpret its raw output.
|
||||
3. Normalize findings into one shape regardless of backend linter: file, line, rule/check, severity, message.
|
||||
4. Do not edit, fix, or rewrite any flagged content. If a finding looks trivially fixable, note that in the report — do not act on it.
|
||||
5. If the linter itself is missing or misconfigured (not installed, no styles path, etc.), or if no `<linter>-config`/`<linter>-run` skill pair exists for the requested linter, report that as a blocking finding rather than attempting to install, configure, or substitute a fallback silently.
|
||||
|
||||
## Output
|
||||
|
||||
Report findings as a flat list, most-severe first:
|
||||
|
||||
```
|
||||
- file: <path>
|
||||
line: <line number or range>
|
||||
rule: <check/rule name>
|
||||
severity: <error | warning | suggestion>
|
||||
message: <finding text>
|
||||
```
|
||||
|
||||
Follow with a one-line summary: total findings by severity, and whether the run was blocked (e.g. linter not configured). If there are zero findings, say so explicitly — do not omit the report.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: lint-runner
|
||||
|
||||
description: Runs a linter sweep over a target file or directory scope and reports findings. Currently backs onto Vale (prose/style linting) via the vale-config and vale-run skills; built to add other linters later without changing its own contract. Use when a caller needs a lint pass run in an isolated context and wants findings back, not fixes applied.
|
||||
|
||||
tools: Bash, Read, Grep, Glob
|
||||
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
|
||||
---
|
||||
|
||||
You are a linter runner. When invoked, you run the appropriate linter(s) over the requested scope, collect their findings, and report them back in a structured, reviewable form. You never edit files.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **scope:** file path, directory path, or glob to lint
|
||||
- **linter:** which linter to run (defaults to `vale` — the only backend currently wired up)
|
||||
- **config context:** any project-specific linter configuration already in place (e.g. an existing `.vale.ini` for Vale, or whatever config format the requested linter expects); if none exists, say so in your report rather than inventing one
|
||||
|
||||
## Process
|
||||
|
||||
1. Determine whether the target scope already has configuration in place for the requested `linter` (whatever config format that linter expects). If not, use the `<linter>-config` skill (e.g. `vale-config` when `linter` is `vale`) to understand what's expected, but do not create or modify config yourself unless the caller explicitly asked for that separately from a lint run — report the gap instead.
|
||||
2. Use the `<linter>-run` skill (e.g. `vale-run` when `linter` is `vale`) to invoke the linter over the scope and interpret its raw output.
|
||||
3. Normalize findings into one shape regardless of backend linter: file, line, rule/check, severity, message.
|
||||
4. Do not edit, fix, or rewrite any flagged content. If a finding looks trivially fixable, note that in the report — do not act on it.
|
||||
5. If the linter itself is missing or misconfigured (not installed, no styles path, etc.), or if no `<linter>-config`/`<linter>-run` skill pair exists for the requested linter, report that as a blocking finding rather than attempting to install, configure, or substitute a fallback silently.
|
||||
|
||||
## Output
|
||||
|
||||
Report findings as a flat list, most-severe first:
|
||||
|
||||
```
|
||||
- file: <path>
|
||||
line: <line number or range>
|
||||
rule: <check/rule name>
|
||||
severity: <error | warning | suggestion>
|
||||
message: <finding text>
|
||||
```
|
||||
|
||||
Follow with a one-line summary: total findings by severity, and whether the run was blocked (e.g. linter not configured). If there are zero findings, say so explicitly — do not omit the report.
|
||||
@@ -1,27 +0,0 @@
|
||||
# docs/
|
||||
|
||||
Plugin documentation. Not read automatically by Claude Code or GitHub Copilot CLI — reference specific files from skill bodies or agent prompts as needed.
|
||||
|
||||
## research/
|
||||
|
||||
Upstream reference material gathered during skill authoring. Not shipped with the plugin — used at development time only.
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `research/docs/vale/` | Vale documentation (vale.sh), gathered while authoring `vale-config`/`vale-run` — see below |
|
||||
|
||||
### research/docs/vale/
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `overview.md` | What Vale is; the style/rule/check configuration model; the built-in `Vale` style's four rules; styles directory layout |
|
||||
| `installation.md` | Installing Vale via OS package managers (Homebrew, Snap, Chocolatey) and Docker |
|
||||
| `configuration.md` | `.vale.ini` structure — global, `[formats]`, and per-glob sections |
|
||||
| `cli-reference.md` | Core invocation and key subcommands/flags (`vale sync`, `vale ls-config`, etc.) |
|
||||
| `examples.md` | Walkthroughs — project initialization, typical `.vale.ini` configs |
|
||||
| `troubleshooting.md` | Suppressing false positives via inline markup (format-specific `vale off`/`vale on` syntax, spelling ignore lists) |
|
||||
| `sources.md` | Provenance record for this directory — source URL, description, and which files above were extracted from it |
|
||||
|
||||
Each file's frontmatter carries a `source_keys` entry keyed to the same provenance record. The plugin-level provenance file, `plugins/lint/sources.md`, cross-references this directory from the `context7-websites-vale-sh` entry and lists the agents/skills whose content drew on it.
|
||||
|
||||
`vale-config` and `vale-run` are the skills that consume this research — read the relevant file here before changing either skill's Vale-facing behavior.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
topic: cli-reference
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## Core Invocation
|
||||
|
||||
```bash
|
||||
$ vale README.md
|
||||
```
|
||||
|
||||
Lints the given file(s)/glob against the styles configured in `.vale.ini`.
|
||||
|
||||
## Key Flags and Subcommands
|
||||
|
||||
| Command/Flag | Purpose |
|
||||
|---|---|
|
||||
| `vale sync` | Downloads and installs packages/styles declared in `.vale.ini`. Run after install and whenever `Packages` changes. |
|
||||
| `vale ls-config` | Prints the currently active, fully-resolved configuration as JSON. Useful for debugging what settings actually apply to a file. |
|
||||
| `--output=<style>` | Sets the output format/template: `line`, `JSON`, `CLI` (default), or a custom template. |
|
||||
| `--no-exit` | Suppresses the non-zero exit code Vale normally returns when `error`-level alerts are found — useful in CI pipelines that shouldn't hard-fail on lint output. |
|
||||
| `--ignore-syntax` | Treats input as plain, unformatted text, skipping syntax-aware parsing (Markdown/HTML/etc). |
|
||||
| `--minAlertLevel=<level>` | Overrides `MinAlertLevel` from the config for this run (`suggestion`, `warning`, `error`). Filters what is displayed; does not affect the exit code. |
|
||||
| `--version` | Prints the Vale binary version. |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
By default, `vale` exits non-zero only when it finds at least one `error`-level alert — this is what makes it usable as a CI gate. `warning` and `suggestion` alerts are printed but still exit `0`, so a rule that must gate CI or a commit hook has to be `level: error`. `MinAlertLevel` and `--minAlertLevel` filter which alerts are displayed and never affect the exit code. Pass `--no-exit` to always exit `0` regardless of findings.
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
topic: configuration
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## `.vale.ini` Structure
|
||||
|
||||
Configuration is INI-formatted with three sections, in order:
|
||||
|
||||
```ini
|
||||
# Core settings appear at the top
|
||||
# (the "global" section).
|
||||
|
||||
[formats]
|
||||
# Format associations appear under
|
||||
# the optional "formats" section.
|
||||
|
||||
[*]
|
||||
# Format-specific settings appear
|
||||
# under a user-provided "glob"
|
||||
# pattern.
|
||||
```
|
||||
|
||||
Core (global) settings apply application-wide; glob sections (`[*]`, `[*.md]`, etc.) scope settings to files matching that pattern.
|
||||
|
||||
## Core Settings
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `StylesPath` | string | Path to all Vale-related resources (styles, dictionaries, vocab). |
|
||||
| `Packages` | string[] | Packages to download and install via `vale sync`. |
|
||||
| `Vocab` | string[] | Vocabularies to load. |
|
||||
| `MinAlertLevel` | enum | Minimum severity to report: `suggestion`, `warning`, or `error`. |
|
||||
| `IgnoredScopes` | enum | Inline-level HTML tags to ignore. |
|
||||
| `SkippedScopes` | enum | Block-level HTML tags to ignore entirely. |
|
||||
|
||||
Example:
|
||||
|
||||
```ini
|
||||
StylesPath = styles
|
||||
MinAlertLevel = suggestion
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = Vale
|
||||
```
|
||||
|
||||
## Format Associations
|
||||
|
||||
Map an unrecognized extension onto a supported one so Vale lints it with the right parser. This is an extension-level substitution only — it does not add new file-type support:
|
||||
|
||||
```ini
|
||||
[formats]
|
||||
mdx = md
|
||||
```
|
||||
|
||||
## Vocabularies
|
||||
|
||||
Reference a named vocabulary (a folder of accept/reject word lists under `StylesPath`) via `Vocab`, then apply styles per glob:
|
||||
|
||||
```ini
|
||||
StylesPath = styles
|
||||
|
||||
Vocab = Blog
|
||||
|
||||
[*]
|
||||
BasedOnStyles = Vale, MyStyle
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
Third-party style packages are declared via `Packages` and then activated per glob with `BasedOnStyles`:
|
||||
|
||||
```ini
|
||||
Packages = Google, write-good
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = Vale, Google, write-good
|
||||
```
|
||||
|
||||
## Local Overrides
|
||||
|
||||
A project can layer a local `.vale.ini` that overrides `StylesPath`, adds packages, and changes `BasedOnStyles` for a subset of files — local settings merge with or override the global ones:
|
||||
|
||||
```ini
|
||||
StylesPath = localpath
|
||||
|
||||
Packages = write-good
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = write-good
|
||||
```
|
||||
|
||||
## Rule Header Fields
|
||||
|
||||
Individual rule YAML files (under a style's directory) support these header fields:
|
||||
|
||||
| Field | Required | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `extends` | yes | — | Check this rule extends (e.g. `existence`). |
|
||||
| `message` | yes | — | Message shown when triggered; supports `%s` formatting per check type. |
|
||||
| `level` | no | `suggestion` | Severity: `suggestion`, `warning`, or `error`. |
|
||||
| `scope` | no | `text` | Scope the rule applies to (e.g. `heading`). |
|
||||
| `link` | no | — | URL with more info about the rule. |
|
||||
| `limit` | no | — | Max number of triggers per file. |
|
||||
| `vocab` | no | `true` | Set `false` to disable active vocabularies for this rule. |
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
topic: examples
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## Project Initialization Walkthrough
|
||||
|
||||
```bash
|
||||
$ cd some-project
|
||||
# create .vale.ini with StylesPath + BasedOnStyles
|
||||
$ vale sync # downloads declared packages/styles into StylesPath
|
||||
$ ls styles # confirms styles were installed
|
||||
$ vale README.md # lint a file
|
||||
```
|
||||
|
||||
The `.vale.ini` file must exist before `vale sync` — it declares which packages to fetch.
|
||||
|
||||
## Typical Project Config
|
||||
|
||||
```ini
|
||||
StylesPath = styles
|
||||
MinAlertLevel = error
|
||||
|
||||
[*.md]
|
||||
BasedOnStyles = ProjectStyle
|
||||
```
|
||||
|
||||
## pre-commit Integration
|
||||
|
||||
Vale ships a pre-commit hook definition. A typical setup runs `vale sync` once (with `pass_filenames: false`) plus the actual lint pass with CI-appropriate flags:
|
||||
|
||||
```yaml
|
||||
repos:
|
||||
- repo: https://github.com/errata-ai/vale
|
||||
rev: 16d3a7f
|
||||
hooks:
|
||||
- id: vale
|
||||
name: vale sync
|
||||
pass_filenames: false
|
||||
args: [sync]
|
||||
- id: vale
|
||||
args: [--output=line, --minAlertLevel=error]
|
||||
```
|
||||
|
||||
## CI Output for Machine Parsing
|
||||
|
||||
```bash
|
||||
$ vale --output=JSON README.md
|
||||
```
|
||||
|
||||
Use `--output=JSON` when a CI step needs to parse results programmatically rather than read the default CLI-formatted output.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
topic: installation
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## Package Managers
|
||||
|
||||
Vale is distributed via standard OS package managers:
|
||||
|
||||
```bash
|
||||
brew install vale # macOS
|
||||
snap install vale # Linux
|
||||
```
|
||||
|
||||
```powershell
|
||||
choco install vale # Windows
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
An official image is available on Docker Hub:
|
||||
|
||||
```bash
|
||||
docker pull jdkato/vale
|
||||
```
|
||||
|
||||
## Post-Install: Syncing Styles
|
||||
|
||||
Installing the `vale` binary alone does not install any styles. After install, run `vale sync` to download and install the styles/packages declared in `.vale.ini`:
|
||||
|
||||
```bash
|
||||
$ vale sync
|
||||
```
|
||||
|
||||
## Format-Specific Extras
|
||||
|
||||
Some input formats need an external converter installed separately before Vale can process them:
|
||||
|
||||
- reStructuredText: `pip install docutils` (provides `rst2html`)
|
||||
- MDX: `npm install -g mdx2vast`
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
topic: overview
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## What Vale Is
|
||||
|
||||
Vale is a cross-platform command-line tool that brings code-like linting to prose. Rather than checking general grammar, it enforces project-specific writing style rules — consistency of terminology, phrasing, and formatting — the same way a linter enforces a code style guide.
|
||||
|
||||
## Styles, Rules, and Checks
|
||||
|
||||
Vale's configuration model has three layers:
|
||||
|
||||
- **Styles** — a named collection of rules (e.g. the built-in `Vale` style, or third-party styles like `Google` or `write-good`). A project can apply multiple styles at once via `BasedOnStyles`.
|
||||
- **Rules** — individual YAML files that define one specific check (e.g. flag a term, enforce a heading capitalization pattern). Each rule `extends` a check and sets a `message`, `level`, and other header fields.
|
||||
- **Checks** — the underlying functions a rule extends to perform analysis: `existence`, `substitution`, `occurrence`, `repetition`, `consistency`, `conditional`, `capitalization`, `metric`, `spelling`, `sequence`, `script`.
|
||||
|
||||
## Built-in Style
|
||||
|
||||
Vale ships with a default `Vale` style containing four rules:
|
||||
|
||||
- `Vale.Spelling` — spell-checks against Hunspell-compatible dictionaries in `<StylesPath>/config/dictionaries`.
|
||||
- `Vale.Terms` — enforces the project's accepted vocabulary terms.
|
||||
- `Vale.Avoid` — enforces the project's rejected vocabulary terms.
|
||||
- `Vale.Repetition` — flags repeated words (e.g. "the the").
|
||||
|
||||
## Styles Directory Layout
|
||||
|
||||
Styles live under `StylesPath` in a nested folder structure, one subdirectory per style, each holding YAML rule files:
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base/
|
||||
│ ├── ComplexWords.yml
|
||||
│ ├── SentenceLength.yml
|
||||
├── blog/
|
||||
│ ├── TechTerms.yml
|
||||
└── docs/
|
||||
├── Branding.yml
|
||||
```
|
||||
|
||||
This lets a project mix a shared base style with format- or section-specific styles, all activated per-glob in `.vale.ini`.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Sources
|
||||
|
||||
## context7-websites-vale-sh
|
||||
|
||||
- **URL:** context7:/websites/vale_sh
|
||||
- **Description:** Official Vale documentation site (vale.sh) indexed by Context7 — `.vale.ini` config reference, style/rule/check model, CLI commands and flags, installation across package managers and Docker, format-specific inline disable syntax, pre-commit integration, spelling ignore lists.
|
||||
- **Contributing files:** overview.md, installation.md, configuration.md, cli-reference.md, examples.md, troubleshooting.md
|
||||
- **Status:** `extracted`
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
topic: troubleshooting
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
---
|
||||
|
||||
## Suppressing False Positives Inline
|
||||
|
||||
Vale supports inline markup comments to disable checks for a section of content. Syntax varies by format:
|
||||
|
||||
Markdown — HTML comments; the MDX `{/* */}` form suppresses nothing in a plain `.md` file:
|
||||
```markdown
|
||||
<!-- vale off -->
|
||||
This text will be ignored.
|
||||
<!-- vale on -->
|
||||
```
|
||||
|
||||
MDX:
|
||||
```mdx
|
||||
{/* vale off */}
|
||||
This text will be ignored.
|
||||
{/* vale on */}
|
||||
```
|
||||
|
||||
Org mode:
|
||||
```org
|
||||
# vale off
|
||||
This text will be ignored.
|
||||
# vale on
|
||||
```
|
||||
|
||||
## Disabling a Specific Rule for Specific Matches
|
||||
|
||||
Rather than disabling all checks, target one rule and specific known-exception strings, then re-enable. Same per-format comment syntax as above — Markdown:
|
||||
|
||||
```markdown
|
||||
<!-- vale Style.Redundancy["ACT test","OTHER"] = NO -->
|
||||
This is some text ACT test
|
||||
<!-- vale Style.Redundancy["ACT test","OTHER"] = YES -->
|
||||
```
|
||||
|
||||
MDX:
|
||||
|
||||
```mdx
|
||||
{/* vale Style.Redundancy["ACT test","OTHER"] = NO */}
|
||||
This is some text ACT test
|
||||
{/* vale Style.Redundancy["ACT test","OTHER"] = YES */}
|
||||
```
|
||||
|
||||
This is the preferred fix for recurring false positives on specific terms — it keeps the rule active everywhere else instead of disabling it project-wide.
|
||||
|
||||
## Ignoring Words in Spell Check
|
||||
|
||||
The `spelling` check accepts an `ignore` list of external plain-text files, so known project-specific terms don't need touching the dictionary:
|
||||
|
||||
```yaml
|
||||
extends: spelling
|
||||
message: "Did you really mean '%s'?"
|
||||
level: error
|
||||
ignore:
|
||||
- ignore1.txt
|
||||
- ignore2.txt
|
||||
```
|
||||
|
||||
## Plain-Text Fallback
|
||||
|
||||
If a file's syntax-aware parsing produces noisy/incorrect results (e.g. an unsupported or malformed format), rerun with `--ignore-syntax` to treat it as plain text instead of relying on the format-specific parser.
|
||||
|
||||
## CI Failing Unexpectedly
|
||||
|
||||
If a CI job fails solely because Vale returns a non-zero exit code on `error`-level alerts (not because the content is actually wrong for that pipeline stage), add `--no-exit` rather than suppressing the rule itself — this preserves the lint output while not gating the build on it. Raising `MinAlertLevel` is not an alternative: it only filters which alerts print, so an `error`-level alert still exits non-zero.
|
||||
|
||||
The mirror-image failure is a Vale gate that never fails. Only `error`-level alerts drive the exit code, so a `warning`- or `suggestion`-level rule prints its alert and still exits `0` — invisible in any CI stage that hides passing output. If a rule must block, give it `level: error`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user