3 Commits

Author SHA1 Message Date
57654c4b02 docs(lint): correct the Vale scalar, size-ceiling and release-gate claims
Four claims in shipped agent-facing docs did not match verified behaviour.
These are read as ground truth by agents in other repos, so each was
reproduced against vale 3.15.2 before rewriting:

- CONTEXT.md and `vale-config/SKILL.md` said both `>` and `|` block scalars
  break the description scope. `|` does not — it lints normally and fires every
  alert, while `>` yields zero. An agent following the old text would rewrite a
  working `|` description into a plain multi-line scalar, which genuinely does
  break, inverting the intended remediation. Both now name the forms that do
  break and state that `|` does not.
- CONTEXT.md and ADR-0013 described the size hook as failing only above 500
  lines, omitting the 2900-word gate it also enforces. Both now describe the
  pair and state that `validate.sh` checks the same two.
- ADR-0014 recorded an accepted residual — a wholesale `assets/` deletion going
  unflagged — that commit 14c2c91 closed. Left as the point-in-time record and
  amended with an update describing the union-with-tag-manifest mechanism,
  following the amendment precedent in ADR-0005.
- `vale-config/SKILL.md` asserted a fresh `.vale.ini` fails until `vale sync`
  runs, contradicting its own note that built-in styles need no download. The
  claim is now scoped to package styles; this repo's two configs declare no
  packages and lint clean with zero syncs.

Also repoints AGENTS.md at the seven `gitea:*` skills — the `bin:gitea` route
it named no longer exists.

Refs: #85
2026-08-09 15:44:28 +00:00
4ae2429840 fix(kyberforge): align the audit word ceiling and drop the fragile --config
Three divergences between what the audit skills claim and what the hooks
enforce, each of which fails silently rather than loudly:

- `skill-size-check.sh` blocked at 2900 words while `validate.sh` checked only
  the 500-line ceiling, so `/skill-audit` could report a skill ready to ship
  that the commit hook then rejected. `validate.sh` now checks the same pair on
  the same inclusive terms; the constants are duplicated with a comment naming
  the other file, because a plugin skill's scripts cannot read outside the
  plugin directory once installed to the cache.
- Both audit skills' Step 1 passed `--config assets/vale/.vale.ini`, which is
  redundant (the wrapper self-locates its sibling config) and fragile: an agent
  that resolves the script path against the skill directory but not the config
  path gets E100, exit 2, which the surrounding fallback clause misreads as
  "vale unavailable" and downgrades to full LLM judgment with no signal.
- The external-consumer test registered only the two Vale hooks, never the
  third shipped hook, so a lost executable bit would have broken every consumer
  while the local suite stayed green. Verified by mutation: `chmod 644` on the
  copied script now turns three passes into two failures.

Also corrects the size hook's calibration comment, which claimed ~5.7-6.5
characters per word against a corpus whose measured median is 6.79 — the stated
upper bound sat below the median, so the "calibrated with margin" claim was
inverted for prose-dense files. MAX_WORDS is unchanged pending a decision; the
comment is now explicit that the gate holds under 5,000 tokens for typical
prose density, not for any file.

Refs: #85
2026-08-09 15:44:16 +00:00
aa8cc22695 fix(lint): flatten every multi-line description form in vale-wrap.sh
Vale locates a frontmatter description by matching the parsed YAML value back
against the source text, so any scalar whose value is not spelled out verbatim
loses the `text.frontmatter.description` scope entirely. The wrapper only
flattened `>` folded scalars, so plain, double-quoted and single-quoted
multi-line descriptions silently reported zero alerts and exit 0 — a clean pass
indistinguishable from a real one, in a prefilter whose callers are instructed
not to re-derive its verdict by judgment.

Implementation notes:

- Classify the scalar kind after `^description:[ \t]*` and reuse one shared
  continuation-line generator for every form; `|` literal blocks keep their
  line breaks, stay verbatim-matchable, and are still left untouched.
- Emit the flattened value in whichever scalar form needs no escape at all
  (plain, then single-quoted, then double-quoted), because any escape breaks
  the verbatim match. The old blanket `'` -> U+2019 substitution silently made
  apostrophe-bearing rule tokens unmatchable across 63% of the corpus; it now
  survives only for the one combination no YAML scalar can carry verbatim.
- Terminate continuations at a line flush with the key, not only on a shallower
  indent — a `description:` followed by a flush-left line previously swallowed
  the rest of the frontmatter.
- Route vale's value-taking flags explicitly instead of inferring targets by
  file existence, and absolutize relative `--output`/`--path` values the way
  `--config` already was, since the run `cd`s into the scratch mirror.
- Fail loudly on a nonexistent path instead of inheriting bare vale's fallback
  to stdin, which rendered a typo'd path as `0 errors ... in stdin`, exit 0 —
  a form the callers' `0 files` NOT-RUN guard cannot match.
- Follow symlinks when walking a directory argument, matching bare vale.

Refs: #85
2026-08-09 15:44:04 +00:00
14 changed files with 1048 additions and 228 deletions

View File

@@ -13,7 +13,7 @@ This repo dogfoods its own plugins. Before shelling out to git, gitea, or lint t
- Commits, branches, history, worktrees, remotes → `git:git-commits`, `git:git-branches`, `git:git-history`, `git:git-worktrees`, `git:git-remotes` - 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` - Pre-commit hook install/config/troubleshooting → `git:pc-run` / `git:pc-author`
- Issues, PRs, labels, milestones → `bin:gitea` - 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` - Vale prose linting → `lint:vale-config` / `lint:vale-run`
- This repo's own AGENTS.md → `core:agentsmd-author` / `core:agentsmd-audit` - This repo's own AGENTS.md → `core:agentsmd-author` / `core:agentsmd-audit`

View File

@@ -72,9 +72,9 @@ A standalone, repo-agnostic plugin (`plugins/lint/`) for configuring and running
### Vale audit prefilter (skill-audit / agent-audit) ### 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. 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 once the description is a YAML block scalar (`>`/`|`) spanning 2+ physical lines, which is how most skills/agents in this repo write it. The wrapper flattens the description 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`; single-line descriptions pass through untouched. 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 still passes `--config assets/vale/.vale.ini` and is unaffected. `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. 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 500-line/5,000-token `SKILL.md` ceiling — failing only above 500 lines, matching `skill-audit/scripts/validate.sh`'s `<= 500` pass — 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. 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=2900` (a word-count proxy for the 5,000-token limit, calibrated from this repo's measured ~1.6-1.7 tokens per word). 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 ### 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. 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.

View File

@@ -95,8 +95,13 @@ every rule to `level: error` is what actually implements this decision.
catch does not pay for a permanent suppression, so the rule is deleted and this ADR's 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. "cherry-picked rules" is one rule, not two.
- A new pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforces the - 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`. It fails only *above* 500-line/5,000-token `SKILL.md` ceiling, sibling to `skill-frontmatter`. Both halves of that
500 lines, matching `skill-audit/scripts/validate.sh`'s long-standing `<= 500` pass. ceiling are blocking gates, not just the line count: `MAX_LINES=500`, and `MAX_WORDS=2900` as a
word-count proxy for the 5,000-token limit (calibrated from this repo's measured ~1.6-1.7 tokens
per word — `wc -w` is not BPE tokenization). Either one exceeded fails the hook. Both are
inclusive: a file at exactly 500 lines or exactly 2,900 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 - `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. future reader doesn't wonder if a trial tier was forgotten.
- The styles-portability question — whether `styles/` and `.vale.ini` should move into - The styles-portability question — whether `styles/` and `.vale.ini` should move into

View File

@@ -151,6 +151,20 @@ doesn't wonder if it was overlooked.
script now derives the bundle's `assets/` tree from `tokens[0]` instead (double-`dirname`, 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 guarded on the candidate existing and on not resolving to `.`), which is the only derivation
compatible with the argument-free `entry:` contract above. compatible with the argument-free `entry:` contract above.
- **Accepted residual in the release gate:** deleting a hook's *entire* `assets/` tree is not - **Accepted residual in the release gate (closed — see the update below):** deleting a hook's
flagged — the derived candidate path stops existing, so the guard drops it before it reaches *entire* `assets/` tree is not flagged — the derived candidate path stops existing, so the guard
the pathspec. Deleting individual files inside a surviving tree is flagged, and tested. 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.

View File

@@ -36,12 +36,12 @@ metadata:
```bash ```bash
bash scripts/validate.sh <path-to-agent-file> bash scripts/validate.sh <path-to-agent-file>
bash scripts/validate-provenance.sh <path-to-agent-file> bash scripts/validate-provenance.sh <path-to-agent-file>
scripts/vale-wrap.sh --config assets/vale/.vale.ini <path-to-cc-file> <path-to-copilot-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`. 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` and `.vale.ini` ship inside this skill's own `scripts/`/`assets/` — resolve them 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. 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 vale or `.vale.ini` 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. `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. `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.

View File

@@ -2,27 +2,45 @@
set -euo pipefail set -euo pipefail
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope # Works around a Vale limitation: the `text.frontmatter.description` NLP scope
# silently stops matching once the `description:` value is a YAML block scalar # silently stops matching once the `description:` value spans 2+ physical lines
# (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in # in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
# this repo. Flattens the description to one physical line in a scratch copy # scalar (the style used by most skills/agents in this repo), a plain scalar
# (padding with blank lines so every other line number is unchanged), then runs # wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
# the real `vale` binary against the copies. Drop-in replacement for calling # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# `vale` directly: same args, same exit code. # 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 one physical line in a scratch
# copy (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 — `--config` values and path arguments alike # "Same args" means relative paths — path arguments and the values of the
# — resolve against the caller's current directory, exactly as bare `vale` # path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
# resolves them. (An earlier version resolved them against the repo root, an # the caller's current directory, exactly as bare `vale` resolves them. The flag
# invented convention that hard-errored on `--config ../../.vale.ini` from a # values are rewritten to absolute form because the run ends up `cd`'d into the
# subdirectory and, worse, silently dropped file arguments that didn't happen to # scratch mirror, where a relative one would no longer resolve. (An earlier
# resolve from the repo root — skipping the flattening this script exists for.) # 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.)
# #
# The one addition to bare `vale`'s argument handling: with no `--config` at # Divergence 1: with no `--config` at all, this script's own sibling
# all, this script's own sibling `assets/vale/.vale.ini` is used instead of # `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo # prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against # `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
# the *consuming* repo and hard-fail (E100) for every external consumer. The # hard-fail (E100) for every external consumer. The manifest therefore passes the
# manifest therefore passes the script alone, and an explicit `--config` from # script alone, and an explicit `--config` from any other caller still wins.
# 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 # 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 # mirrors the caller's absolute cwd: a relative path argument is passed through
@@ -41,22 +59,44 @@ cwd="$(pwd -P)"
# later edit breaking that invariant, not a live fix. # later edit breaking that invariant, not a live fix.
vale_args=() vale_args=()
path_args=() path_args=()
config_next=false pending_flag=""
config_given=false config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ -n "$pending_flag" ]]; then
config_next=false # Value of a separated two-argv flag. It is never a lint target, however
if [[ "$arg" == /* ]]; then # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
vale_args+=("$arg") # value naming a file has to be absolutized here or it stops resolving.
else case "$pending_flag" in
vale_args+=("$cwd/$arg") --config)
fi # Always a path, and required to exist.
if [[ "$arg" == /* ]]; then
vale_args+=("$arg")
else
vale_args+=("$cwd/$arg")
fi
;;
--output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a
# template file; only the file form needs resolving. `--path` is
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg")
else
vale_args+=("$arg")
fi
;;
*)
vale_args+=("$arg")
;;
esac
pending_flag=""
continue continue
fi fi
case "$arg" in case "$arg" in
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true pending_flag="$arg"
config_given=true config_given=true
continue continue
;; ;;
@@ -70,24 +110,60 @@ for arg in "$@"; do
config_given=true config_given=true
continue continue
;; ;;
# Same cwd-relative resolution for the `--flag=value` spelling of the two
# other path-valued flags.
--output=*|--path=*)
flag_val="${arg#*=}"
if [[ "$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 esac
# `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. if [[ "$arg" == -* ]]; then
# A path that doesn't exist is left for vale to report on, exactly as bare
# vale would.
if [[ "$arg" != -* && ( -f "$arg" || -d "$arg" ) ]]; then
# 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
else
vale_args+=("$arg") 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 fi
done done
@@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2]
with open(src, encoding='utf-8', errors='surrogateescape') as fh: with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() 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 one-line 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."""
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 verbatim YAML scalar can carry it. Substituting
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only —
# never written back to the real file.
return "'" + value.replace("'", '’') + "'"
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
if fm_match: if fm_match:
fm = fm_match.group(2) fm = fm_match.group(2)
# Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
# scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale, else:
# so they're deliberately left unmatched here. header_m = None
header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE)
if header_m: if header_m:
# Body capture is indentation-based and blank-line-tolerant, per YAML head_start = header_m.start()
# block-scalar rules: a blank line (any amount of whitespace) always value_start = header_m.end()
# stays inside the block; the indent is set by the first content line; header_end = fm.find('\n', value_start)
# the block ends at the first line indented less than that, or EOF. header_end = len(fm) if header_end == -1 else header_end
rest = fm[header_m.end():] first = fm[value_start:header_end]
indent = None body_start = header_end + 1
body_lines = [] indicator = first.rstrip()
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n') block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
if text.strip() == '': if block_m and block_m.group(1) == '|':
body_lines.append(line) kind = None # literal blocks keep their line breaks; vale is fine
continue elif block_m:
line_indent = len(text) - len(text.lstrip(' \t')) kind = 'block' # folded (`>`): the value starts on the next line
if indent is None: elif indicator == '':
indent = line_indent kind = 'block' # bare `description:`: a plain scalar on later lines
elif line_indent < indent: 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 break
body_lines.append(line) idx = close_quote(acc, quote)
raw = ''.join(body_lines) if idx is None:
if raw.count('\n') >= 2: kind = None # unterminated quote: invalid YAML, leave it to vale
flat = re.sub(r'\s+', ' ', raw).strip() else:
# YAML single-quoted scalars have no backslash-escape mechanism at inner = acc[:idx]
# all, so wrapping in single quotes sidesteps the backslash-escape value_end = inner_start + idx + 1
# bug entirely for embedded double quotes, backslashes, and text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
# non-ASCII text. The one YAML-spec-correct way to embed a literal value_lines = 1 + inner.count('\n')
# apostrophe is to double it ('') — but Vale's own frontmatter
# scanner isn't a full YAML parser and doesn't understand that flat = re.sub(r'\s+', ' ', text).strip()
# doubling: empirically, it silently truncates the value at the if kind and flat and value_lines >= 2:
# first ' it sees, hiding everything after it from the NLP scope # `value_end` can land mid-line, just past a closing quote, so extend to
# (a different flavor of the same bug this whole script exists to # the end of that physical line and carry whatever follows (a trailing
# work around). Since this copy is scratch-only and never written # comment) across unchanged.
# back, sidestep it by substituting a Unicode right single if value_end > 0 and fm[value_end - 1] == '\n':
# quotation mark (U+2019) for any literal apostrophe instead of span_end = value_end
# doubling it — visually a smart quote, but never triggers a YAML trailer = ''
# escape sequence at all. else:
flat_q = "'" + flat.replace("'", "’") + "'" newline = fm.find('\n', value_end)
pad = '\n' * raw.count('\n') span_end = len(fm) if newline == -1 else newline + 1
start = header_m.start() trailer = fm[value_end:span_end].rstrip('\n')
end = header_m.end() + len(raw) # One line replaces the span, so the blank-line pad is one short of the
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] # newline count it displaced — every later line number is unchanged.
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] pad = '\n' * (fm[head_start:span_end].count('\n') - 1)
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer
+ '\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: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
@@ -206,11 +437,16 @@ for arg in ${path_args[@]+"${path_args[@]}"}; do
# the tree, so any file dropped here would be silently unlinted — and then # 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: # 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. # 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" mkdir -p "$dest"
while IFS= read -r -d '' rel; do while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")" mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel" cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0) done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do while IFS= read -r -d '' md; do
flatten "$md" "$md" flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0) done < <(find "$dest" -type f -name '*.md' -print0)

View File

@@ -34,14 +34,14 @@ metadata:
```bash ```bash
bash scripts/validate.sh <skill-dir> bash scripts/validate.sh <skill-dir>
bash scripts/validate-provenance.sh <skill-dir> bash scripts/validate-provenance.sh <skill-dir>
scripts/vale-wrap.sh --config assets/vale/.vale.ini <skill-dir>/SKILL.md 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, 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. 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` and `.vale.ini` ship inside this skill's own `scripts/`/`assets/` — resolve them 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. It applies `.vale.ini`'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 vale or `.vale.ini` 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. `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 ## Step 2 — Read all skill files

View File

@@ -2,27 +2,45 @@
set -euo pipefail set -euo pipefail
# Works around a Vale limitation: the `text.frontmatter.description` NLP scope # Works around a Vale limitation: the `text.frontmatter.description` NLP scope
# silently stops matching once the `description:` value is a YAML block scalar # silently stops matching once the `description:` value spans 2+ physical lines
# (`>`/`|`) spanning 2+ physical lines — the style used by most skills/agents in # in any form YAML joins back into one string — a `>`/`>-`/`>+` folded block
# this repo. Flattens the description to one physical line in a scratch copy # scalar (the style used by most skills/agents in this repo), a plain scalar
# (padding with blank lines so every other line number is unchanged), then runs # wrapped onto continuation lines, or a double- or single-quoted scalar wrapped
# the real `vale` binary against the copies. Drop-in replacement for calling # the same way. A `|`/`|-`/`|+` literal block scalar is NOT affected: its parsed
# `vale` directly: same args, same exit code. # 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 one physical line in a scratch
# copy (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 — `--config` values and path arguments alike # "Same args" means relative paths — path arguments and the values of the
# — resolve against the caller's current directory, exactly as bare `vale` # path-valued flags (`--config`, `--output`, `--path`) alike — resolve against
# resolves them. (An earlier version resolved them against the repo root, an # the caller's current directory, exactly as bare `vale` resolves them. The flag
# invented convention that hard-errored on `--config ../../.vale.ini` from a # values are rewritten to absolute form because the run ends up `cd`'d into the
# subdirectory and, worse, silently dropped file arguments that didn't happen to # scratch mirror, where a relative one would no longer resolve. (An earlier
# resolve from the repo root — skipping the flattening this script exists for.) # 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.)
# #
# The one addition to bare `vale`'s argument handling: with no `--config` at # Divergence 1: with no `--config` at all, this script's own sibling
# all, this script's own sibling `assets/vale/.vale.ini` is used instead of # `assets/vale/.vale.ini` is used instead of vale's upward search. pre-commit
# vale's upward search. pre-commit prefixes only `entry[0]` with the hook-repo # prefixes only `entry[0]` with the hook-repo clone path, so a `--config` in
# clone path, so a `--config` in `.pre-commit-hooks.yaml` would resolve against # `.pre-commit-hooks.yaml` would resolve against the *consuming* repo and
# the *consuming* repo and hard-fail (E100) for every external consumer. The # hard-fail (E100) for every external consumer. The manifest therefore passes the
# manifest therefore passes the script alone, and an explicit `--config` from # script alone, and an explicit `--config` from any other caller still wins.
# 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 # 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 # mirrors the caller's absolute cwd: a relative path argument is passed through
@@ -41,22 +59,44 @@ cwd="$(pwd -P)"
# later edit breaking that invariant, not a live fix. # later edit breaking that invariant, not a live fix.
vale_args=() vale_args=()
path_args=() path_args=()
config_next=false pending_flag=""
config_given=false config_given=false
for arg in "$@"; do for arg in "$@"; do
if [[ "$config_next" == true ]]; then if [[ -n "$pending_flag" ]]; then
config_next=false # Value of a separated two-argv flag. It is never a lint target, however
if [[ "$arg" == /* ]]; then # file-like it looks. The run ends up `cd`'d into the scratch mirror, so a
vale_args+=("$arg") # value naming a file has to be absolutized here or it stops resolving.
else case "$pending_flag" in
vale_args+=("$cwd/$arg") --config)
fi # Always a path, and required to exist.
if [[ "$arg" == /* ]]; then
vale_args+=("$arg")
else
vale_args+=("$cwd/$arg")
fi
;;
--output|--path)
# `--output` is either a built-in style name (`line`, `JSON`) or a
# template file; only the file form needs resolving. `--path` is
# likewise a path. Anything that names nothing is passed through and
# left for vale to interpret.
if [[ "$arg" != /* && -e "$arg" ]]; then
vale_args+=("$cwd/$arg")
else
vale_args+=("$arg")
fi
;;
*)
vale_args+=("$arg")
;;
esac
pending_flag=""
continue continue
fi fi
case "$arg" in case "$arg" in
--config) --config)
vale_args+=("$arg") vale_args+=("$arg")
config_next=true pending_flag="$arg"
config_given=true config_given=true
continue continue
;; ;;
@@ -70,24 +110,60 @@ for arg in "$@"; do
config_given=true config_given=true
continue continue
;; ;;
# Same cwd-relative resolution for the `--flag=value` spelling of the two
# other path-valued flags.
--output=*|--path=*)
flag_val="${arg#*=}"
if [[ "$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 esac
# `-f`/`-d` resolve relative paths against the caller's cwd, same as vale does. if [[ "$arg" == -* ]]; then
# A path that doesn't exist is left for vale to report on, exactly as bare
# vale would.
if [[ "$arg" != -* && ( -f "$arg" || -d "$arg" ) ]]; then
# 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
else
vale_args+=("$arg") 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 fi
done done
@@ -119,55 +195,210 @@ src, dest = sys.argv[1], sys.argv[2]
with open(src, encoding='utf-8', errors='surrogateescape') as fh: with open(src, encoding='utf-8', errors='surrogateescape') as fh:
content = fh.read() 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 one-line 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."""
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 verbatim YAML scalar can carry it. Substituting
# U+2019 for the apostrophe keeps the scope alive, at the cost of any style
# rule whose token contains a literal ASCII apostrophe. Scratch-copy only —
# never written back to the real file.
return "'" + value.replace("'", '’') + "'"
fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL) fm_match = re.match(r'^(---\n)(.*?\n)(---\n)', content, re.DOTALL)
if fm_match: if fm_match:
fm = fm_match.group(2) fm = fm_match.group(2)
# Only `>`/`>-`/`>+` (folded) scalars break Vale's frontmatter-description header_m = re.search(r'^description:[ \t]*', fm, re.MULTILINE)
# scope. `|`/`|-`/`|+` (literal) scalars already work fine with bare vale, else:
# so they're deliberately left unmatched here. header_m = None
header_m = re.search(r'^description:[ \t]*(>[+-]?)[ \t]*\n', fm, re.MULTILINE)
if header_m: if header_m:
# Body capture is indentation-based and blank-line-tolerant, per YAML head_start = header_m.start()
# block-scalar rules: a blank line (any amount of whitespace) always value_start = header_m.end()
# stays inside the block; the indent is set by the first content line; header_end = fm.find('\n', value_start)
# the block ends at the first line indented less than that, or EOF. header_end = len(fm) if header_end == -1 else header_end
rest = fm[header_m.end():] first = fm[value_start:header_end]
indent = None body_start = header_end + 1
body_lines = [] indicator = first.rstrip()
for line in rest.splitlines(keepends=True):
text = line.rstrip('\n') block_m = re.fullmatch(r'([|>])([+-]?[0-9]*|[0-9]*[+-]?)', indicator)
if text.strip() == '': if block_m and block_m.group(1) == '|':
body_lines.append(line) kind = None # literal blocks keep their line breaks; vale is fine
continue elif block_m:
line_indent = len(text) - len(text.lstrip(' \t')) kind = 'block' # folded (`>`): the value starts on the next line
if indent is None: elif indicator == '':
indent = line_indent kind = 'block' # bare `description:`: a plain scalar on later lines
elif line_indent < indent: 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 break
body_lines.append(line) idx = close_quote(acc, quote)
raw = ''.join(body_lines) if idx is None:
if raw.count('\n') >= 2: kind = None # unterminated quote: invalid YAML, leave it to vale
flat = re.sub(r'\s+', ' ', raw).strip() else:
# YAML single-quoted scalars have no backslash-escape mechanism at inner = acc[:idx]
# all, so wrapping in single quotes sidesteps the backslash-escape value_end = inner_start + idx + 1
# bug entirely for embedded double quotes, backslashes, and text = unescape_double(inner) if quote == '"' else inner.replace("''", "'")
# non-ASCII text. The one YAML-spec-correct way to embed a literal value_lines = 1 + inner.count('\n')
# apostrophe is to double it ('') — but Vale's own frontmatter
# scanner isn't a full YAML parser and doesn't understand that flat = re.sub(r'\s+', ' ', text).strip()
# doubling: empirically, it silently truncates the value at the if kind and flat and value_lines >= 2:
# first ' it sees, hiding everything after it from the NLP scope # `value_end` can land mid-line, just past a closing quote, so extend to
# (a different flavor of the same bug this whole script exists to # the end of that physical line and carry whatever follows (a trailing
# work around). Since this copy is scratch-only and never written # comment) across unchanged.
# back, sidestep it by substituting a Unicode right single if value_end > 0 and fm[value_end - 1] == '\n':
# quotation mark (U+2019) for any literal apostrophe instead of span_end = value_end
# doubling it — visually a smart quote, but never triggers a YAML trailer = ''
# escape sequence at all. else:
flat_q = "'" + flat.replace("'", "’") + "'" newline = fm.find('\n', value_end)
pad = '\n' * raw.count('\n') span_end = len(fm) if newline == -1 else newline + 1
start = header_m.start() trailer = fm[value_end:span_end].rstrip('\n')
end = header_m.end() + len(raw) # One line replaces the span, so the blank-line pad is one short of the
new_fm = fm[:start] + f'description: {flat_q}\n{pad}' + fm[end:] # newline count it displaced — every later line number is unchanged.
content = fm_match.group(1) + new_fm + fm_match.group(3) + content[fm_match.end():] pad = '\n' * (fm[head_start:span_end].count('\n') - 1)
new_fm = (fm[:head_start] + 'description: ' + emit(flat) + trailer
+ '\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: with open(dest, 'w', encoding='utf-8', errors='surrogateescape') as fh:
fh.write(content) fh.write(content)
@@ -206,11 +437,16 @@ for arg in ${path_args[@]+"${path_args[@]}"}; do
# the tree, so any file dropped here would be silently unlinted — and then # 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: # 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. # 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" mkdir -p "$dest"
while IFS= read -r -d '' rel; do while IFS= read -r -d '' rel; do
mkdir -p "$dest/$(dirname "$rel")" mkdir -p "$dest/$(dirname "$rel")"
cp "$arg/$rel" "$dest/$rel" cp "$arg/$rel" "$dest/$rel"
done < <(cd "$arg" && find . -name .git -prune -o -type f -print0) done < <(cd "$arg" && find -L . -name .git -prune -o -type f -print0)
while IFS= read -r -d '' md; do while IFS= read -r -d '' md; do
flatten "$md" "$md" flatten "$md" "$md"
done < <(find "$dest" -type f -name '*.md' -print0) done < <(find "$dest" -type f -name '*.md' -print0)

View File

@@ -133,12 +133,28 @@ else:
if desc: if desc:
ok("description has no unfilled placeholders") ok("description has no unfilled placeholders")
# SKILL.md line count # 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
MAX_WORDS = 2900 # word-count proxy for the ~5,000-token ceiling
line_count = len(content.splitlines()) line_count = len(content.splitlines())
if line_count <= 500: if line_count <= MAX_LINES:
ok(f"SKILL.md line count {line_count} (limit: 500)") ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
else: else:
fail(f"SKILL.md line count {line_count} — exceeds 500-line limit") 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)")
# Body unfilled placeholders # Body unfilled placeholders
body = content[body_start:] body = content[body_start:]

View File

@@ -19,10 +19,10 @@ metadata:
## Gotchas ## Gotchas
- Installing the `vale` binary installs no styles. A fresh `.vale.ini` with `BasedOnStyles` set will fail or find nothing until `vale sync` runs and downloads the `Packages` it declares. - Installing the `vale` binary installs no styles, but only *package* styles need fetching. A fresh `.vale.ini` naming a style in `BasedOnStyles` that is declared in `Packages` will fail or find nothing until `vale sync` downloads it. A built-in style (`Vale`) or a style whose YAML rule files are already committed under `StylesPath` lints immediately, with no `Packages` entry and no sync.
- `.vale.ini` is order-sensitive: global (core) settings first, then the optional `[formats]` section, then glob sections (`[*]`, `[*.md]`, …). Settings in a glob section only apply to files matching that glob. - `.vale.ini` is order-sensitive: global (core) settings first, then the optional `[formats]` section, then glob sections (`[*]`, `[*.md]`, …). Settings in a glob section only apply to files matching that glob.
- `Packages` (top-level, fetched by `vale sync`) and `BasedOnStyles` (per-glob, activates) are separate keys — a style only lints files once it's in both. This is the step people forget. - `Packages` (top-level, fetched by `vale sync`) and `BasedOnStyles` (per-glob, activates) are separate keys — a style only lints files once it's in both. This is the step people forget.
- A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) only reliably matches when that field's value is a single physical line. If it's a YAML block scalar (`>`/`|`) spanning 2+ physical lines, the scope silently stops matching — no error, just 0 findings — confirmed against Vale 3.15.2. Verify with a deliberately-bad multi-line fixture before trusting a frontmatter-scoped rule in production; if the field is commonly authored as a multi-line block scalar, flatten it to one line ahead of the `vale` call rather than relying on the scope alone. - A rule scoped to `text.frontmatter.<key>` (e.g. `text.frontmatter.description`) matches reliably when that field's value is a single physical line, and breaks on most — not all — multi-line forms. Confirmed against Vale 3.15.2 with a deliberately-bad fixture: a `>` folded block scalar, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars each yield 0 findings and exit 0, silently and with no error; a `|` literal block scalar spanning the same 2+ lines lints normally and exits 1. Do not assume `|` and `>` behave alike — reproduce both against your own config before trusting a frontmatter-scoped rule in production. If the field is commonly authored in one of the broken forms, flatten it to one physical line ahead of the `vale` call rather than relying on the scope alone.
## Setup workflow ## Setup workflow

View File

@@ -15,12 +15,23 @@ set -euo pipefail
# audit and still be blocked by the commit hook. # audit and still be blocked by the commit hook.
# #
# Token counts aren't computed exactly here — word count (`wc -w`) is used as # Token counts aren't computed exactly here — word count (`wc -w`) is used as
# a proxy. This repo's own SKILL.md corpus measures ~5.7-6.5 characters per # a proxy. Measured over this repo's 39 in-scope SKILL.md files, characters per
# word, which at the standard ~4-characters-per-token English approximation # word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard
# works out to roughly 1.6-1.7 tokens per word. MAX_WORDS below is calibrated # ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 /
# from that measured ratio against the 5,000-token ceiling, with margin — it's # 1.81 tokens per word.
# still a proxy, not exact BPE tokenization, but now grounded in actual repo #
# content rather than an unverified "conservative" assumption. # MAX_WORDS=2900 is therefore calibrated to the corpus MEDIAN, not to its worst
# case, and carries no margin: 2900 x 1.70 = ~4,900 tokens for a median-density
# file, but 2900 x 1.81 = ~5,240 tokens for the densest file in the corpus. So
# what this gate actually guarantees is "under 5,000 tokens for a file of
# typical prose density"; a prose-dense SKILL.md can sit at exactly MAX_WORDS
# and still be a few hundred tokens over the agentskills.io ceiling. Holding
# the worst observed ratio under 5,000 tokens would need MAX_WORDS ~2770.
#
# It is a one-sided proxy in the useful direction — nothing under the word
# ceiling is wildly over the token ceiling — but it is not exact BPE
# tokenization and does not replace one. Re-measure the corpus before treating
# any of these numbers as still current.
MAX_LINES=500 MAX_LINES=500
MAX_WORDS=2900 MAX_WORDS=2900

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regression test for scripts/skill-size-check.sh: enforces agentskills.io's # Regression test for scripts/skill-size-check.sh: enforces agentskills.io's
# 500-line/5,000-word(proxy-for-token) SKILL.md size ceiling. # 500-line/5,000-token SKILL.md size ceiling. The token half is enforced via a
# word-count proxy (MAX_WORDS, currently 2900) — 5,000 is the token ceiling,
# 2,900 is the word budget the script derives from it.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -55,9 +57,9 @@ echo ""
echo "--- fails a file over the word-count limit ---" echo "--- fails a file over the word-count limit ---"
MANY_WORDS="$(make_fixture many-words 10 600)" MANY_WORDS="$(make_fixture many-words 10 600)"
if "$SCRIPT" "$MANY_WORDS" 2>/dev/null; then if "$SCRIPT" "$MANY_WORDS" 2>/dev/null; then
fail "file over the 5,000-word ceiling should have exited non-zero" fail "file over the word ceiling should have exited non-zero"
else else
pass "file over the 5,000-word ceiling exits non-zero" pass "file over the word ceiling exits non-zero"
fi fi
# Boundary-pair tests below read the script's current MAX_WORDS rather than # Boundary-pair tests below read the script's current MAX_WORDS rather than

View File

@@ -47,8 +47,11 @@ git -C "$HOOK_REPO" add -A
git -C "$HOOK_REPO" -c user.email=test@example.invalid -c user.name=test commit -qm "hook repo" git -C "$HOOK_REPO" -c user.email=test@example.invalid -c user.name=test commit -qm "hook repo"
HOOK_REV="$(git -C "$HOOK_REPO" rev-parse HEAD)" HOOK_REV="$(git -C "$HOOK_REPO" rev-parse HEAD)"
# The two Vale hooks scope by filename, so the consumer needs one file of each # Every hook scopes by filename, so the consumer needs one file of each shape:
# shape: a hook with nothing to match reports `Skipped` and proves nothing. # a hook with nothing to match reports `Skipped` and proves nothing. All three
# hooks .pre-commit-hooks.yaml ships are registered — an unregistered one would
# let a regression (a lost `100755` bit, a bad entry path) reach every external
# consumer while this repo's own `repo: local` runs stayed green.
mkdir -p "$CONSUMER/skills/demo" "$CONSUMER/agents" mkdir -p "$CONSUMER/skills/demo" "$CONSUMER/agents"
git -C "$CONSUMER" init -q git -C "$CONSUMER" init -q
cat > "$CONSUMER/.pre-commit-config.yaml" <<EOF cat > "$CONSUMER/.pre-commit-config.yaml" <<EOF
@@ -58,6 +61,7 @@ repos:
hooks: hooks:
- id: kyberforge-vale-audit-skill - id: kyberforge-vale-audit-skill
- id: kyberforge-vale-audit-agent - id: kyberforge-vale-audit-agent
- id: kyberforge-skill-size-check
EOF EOF
write_fixtures() { write_fixtures() {
@@ -109,19 +113,49 @@ fi
# --- 2. Clean files pass — the hooks gate, they don't just always fail --- # --- 2. Clean files pass — the hooks gate, they don't just always fail ---
echo "" echo ""
echo "--- both Vale hooks pass clean files in an external consumer repo ---" echo "--- all three hooks pass clean files in an external consumer repo ---"
write_fixtures "of the packaged hook contract" write_fixtures "of the packaged hook contract"
set +e set +e
(cd "$CONSUMER" && pre-commit run --all-files > "$WORK/clean.log" 2>&1) (cd "$CONSUMER" && pre-commit run --all-files > "$WORK/clean.log" 2>&1)
RC_CLEAN=$? RC_CLEAN=$?
set -e set -e
if [[ $RC_CLEAN -eq 0 ]]; then if grep -q "Skipped" "$WORK/clean.log"; then
pass "both hooks exit 0 on clean files" fail "a hook matched no files on the clean run, so it proved nothing"
sed 's/^/ /' "$WORK/clean.log"
elif [[ $RC_CLEAN -eq 0 ]]; then
pass "all three hooks exit 0 on clean files"
else else
fail "hooks failed on clean files (rc=$RC_CLEAN)" fail "hooks failed on clean files (rc=$RC_CLEAN)"
sed 's/^/ /' "$WORK/clean.log" sed 's/^/ /' "$WORK/clean.log"
fi fi
# --- 3. The size hook gates too. It ran clean above, which is what proves it
# is executable and its entry path resolves; this half proves it still fails a
# file that breaks the ceiling rather than passing everything. ---
echo ""
echo "--- kyberforge-skill-size-check fails an oversized SKILL.md in an external consumer repo ---"
mkdir -p "$CONSUMER/skills/oversized"
{
echo "---"
echo "name: oversized"
echo "description: Use when the caller wants an oversized fixture."
echo "---"
for ((i = 1; i <= 600; i++)); do
echo "word"
done
} > "$CONSUMER/skills/oversized/SKILL.md"
git -C "$CONSUMER" add -A
set +e
(cd "$CONSUMER" && pre-commit run kyberforge-skill-size-check --all-files > "$WORK/size.log" 2>&1)
RC_SIZE=$?
set -e
if [[ $RC_SIZE -ne 0 ]] && grep -q "500-line ceiling" "$WORK/size.log"; then
pass "kyberforge-skill-size-check exits non-zero and names the ceiling it broke"
else
fail "kyberforge-skill-size-check did not gate an oversized SKILL.md (rc=$RC_SIZE)"
sed 's/^/ /' "$WORK/size.log"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]

View File

@@ -321,7 +321,12 @@ else
fail "an absolute path was silently skipped — the bug this test guards against" fail "an absolute path was silently skipped — the bug this test guards against"
fi fi
# --- 11. A literal (|) block scalar passes through unflattened (no regression) --- # --- 11. A literal (|) block scalar passes through unflattened. Unlike every
# other multi-line form, `|` is not broken in Vale: its parsed value keeps the
# same line breaks the source has, so the description scope still matches. The
# second assertion pins that down — without it, a wrapper that broke `|` and a
# Vale that never matched `|` would agree on zero alerts and the comparison
# would pass vacuously.
echo "" echo ""
echo "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---" echo "--- leaves a literal (|) block scalar untouched (narrowed >-only scope) ---"
FIXTURE11="$(make_raw_fixture <<'EOF' FIXTURE11="$(make_raw_fixture <<'EOF'
@@ -339,7 +344,9 @@ trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTU
REL11="plugins/testplugin/skills/zzzskill/SKILL.md" REL11="plugins/testplugin/skills/zzzskill/SKILL.md"
WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11") WRAPPED_OUT=$(run_wrap "$FIXTURE11" --config "$VALE_CONFIG" "$REL11")
BARE_OUT=$(cd "$FIXTURE11" && vale --config "$VALE_CONFIG" "$REL11" 2>&1 || true) BARE_OUT=$(cd "$FIXTURE11" && vale --config "$VALE_CONFIG" "$REL11" 2>&1 || true)
if [[ "$WRAPPED_OUT" == "$BARE_OUT" ]]; then if ! echo "$BARE_OUT" | grep -q "VagueWording"; then
fail "bare vale reports nothing for a literal (|) block scalar — the 'literal blocks are not broken' premise is wrong"
elif [[ "$WRAPPED_OUT" == "$BARE_OUT" ]]; then
pass "literal (|) block scalar output matches bare vale exactly — untouched by flattening" pass "literal (|) block scalar output matches bare vale exactly — untouched by flattening"
else else
fail "wrapper altered output for a literal (|) block scalar description — should be left untouched" fail "wrapper altered output for a literal (|) block scalar description — should be left untouched"
@@ -425,23 +432,50 @@ else
fail "a path with a space was dropped from the directory walk" fail "a path with a space was dropped from the directory walk"
fi fi
# --- 16. No unguarded `"${arr[@]}"` expansion survives in the wrapper. bash # --- 16. No unguarded `"${arr[@]}"` expansion survives in any script that runs
# before 4.4 — including the 3.2 that macOS still ships as /bin/bash — treats # on macOS. bash before 4.4 — including the 3.2 that macOS still ships as
# that form on an empty array as an unbound variable under `set -u` and aborts. # /bin/bash — treats that form on an *empty* array as an unbound variable under
# The portable form is `${arr[@]+"${arr[@]}"}`. This is a static check because # `set -u` and aborts. The portable form is `${arr[@]+"${arr[@]}"}`. This is a
# no bash 5 host can reproduce the abort at runtime: the construct is only fatal # static check because no bash 5 host can reproduce the abort at runtime: the
# on the older shell, so absence of the construct is the property to assert. # construct is only fatal on the older shell, so absence of the construct is the
# `${#arr[@]}` is deliberately not flagged — the count form is safe on 3.2. # property to assert. `${#arr[@]}` is deliberately not flagged — the count form
# is safe on 3.2. Neither is an array seeded with at least one element where it
# is declared and never reset to empty: it cannot be empty at any expansion
# site, so the construct is not a hazard there and demanding the guarded form
# would be a wrong test. The file list covers every script this repo ships or
# runs that a macOS user reaches: the wrapper itself plus the two pre-commit
# hook scripts.
echo "" echo ""
echo "--- no unguarded array expansion remains in vale-wrap.sh ---" echo "--- no unguarded array expansion remains in the macOS-facing scripts ---"
unguarded_expansions() { unguarded_expansions() {
# Blank out whole-line comments (keeping line numbers), delete every correctly local file="$1" hit name
# guarded expansion, then anything still matching is a real hazard. while IFS= read -r hit; do
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$1" \ name="$(printf '%s\n' "$hit" \
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \ | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' | head -1 \
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true | sed -E 's/^\$\{//; s/\[@\]\}$//')"
if grep -qE "^[[:space:]]*((local|declare|readonly)[[:space:]]+)?(-a[[:space:]]+)?$name=\([^)]" "$file" \
&& ! grep -qE "^[[:space:]]*$name=\(\)" "$file"; then
continue
fi
printf '%s:%s\n' "${file##*/}" "$hit"
done < <(
# Blank out whole-line comments (keeping line numbers), delete every
# correctly guarded expansion, then anything still matching is a candidate.
awk '{ if ($0 ~ /^[[:space:]]*#/) print ""; else print }' "$file" \
| sed -E 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\+"\$\{\1\[@\]\}"\}//g' \
| grep -nE '\$\{[A-Za-z_][A-Za-z0-9_]*\[@\]\}' || true
)
} }
HAZARDS16="$(unguarded_expansions "$SCRIPT")" HAZARDS16=""
for BASH32_SCRIPT in \
"$SCRIPT" \
"$REPO_ROOT/scripts/skill-size-check.sh" \
"$REPO_ROOT/scripts/check-release-needed.sh"; do
FOUND16="$(unguarded_expansions "$BASH32_SCRIPT")"
if [[ -n "$FOUND16" ]]; then
HAZARDS16+="$FOUND16 "
fi
done
if [[ -n "$HAZARDS16" ]]; then if [[ -n "$HAZARDS16" ]]; then
fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')" fail "unguarded array expansion(s) abort on bash < 4.4 under set -u: $(echo "$HAZARDS16" | tr '\n' ' ')"
else else
@@ -500,6 +534,238 @@ else
fail "a path argument with a space was split by the array expansion: $OUT18" fail "a path argument with a space was split by the array expansion: $OUT18"
fi fi
# The cases below share one cleanup list. The per-case trap rebuilding above
# does not scale past the fixture count it already carries, and this trap is
# installed last, so it is the one that runs.
EXTRA_FIXTURES=()
new_fixture() { EXTRA_FIXTURES+=("$1"); }
cleanup_all() {
rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5" "$FIXTURE6" \
"$FIXTURE7" "$FIXTURE8" "$FIXTURE10" "$FIXTURE11" "$FIXTURE12" "$STUB13" \
"$FIXTURE14" "$FIXTURE17" "$FIXTURE18" \
${EXTRA_FIXTURES[@]+"${EXTRA_FIXTURES[@]}"}
}
trap cleanup_all EXIT
# --- 19. Every YAML form whose parsed value is joined back out of 2+ physical
# lines breaks the `text.frontmatter.description` scope identically, not just
# the `>` folded block the flattener originally handled: a plain scalar wrapped
# onto continuation lines, a double-quoted one, a single-quoted one, and a bare
# `description:` whose value starts on the next line all report zero alerts
# under bare vale. Each must come back with the same alerts as the single-line
# spelling of the same sentence. Line and column numbers legitimately move (the
# value lands on one physical line), so the comparison drops the `line:col`
# prefix and compares the alert text — message, matched token, and rule name.
REL_SKILL19="plugins/testplugin/skills/zzzskill/SKILL.md"
DESC19_A="Use when the caller helps with a specific job"
DESC19_B="and the second physical line will utilize the wrap"
# make_form_fixture spells the same two-clause description in one YAML scalar
# form: single, folded, plain, dquote, squote, or keyonly.
make_form_fixture() {
local form="$1" dir
dir="$(mktemp -d)"
new_fixture "$dir"
(cd "$dir" && git init -q)
mkdir -p "$dir/plugins/testplugin/skills/zzzskill"
{
echo "---"
echo "name: zzzskill"
case "$form" in
single) echo "description: $DESC19_A $DESC19_B" ;;
folded) echo "description: >"; echo " $DESC19_A"; echo " $DESC19_B" ;;
plain) echo "description: $DESC19_A"; echo " $DESC19_B" ;;
dquote) echo "description: \"$DESC19_A"; echo " $DESC19_B\"" ;;
squote) echo "description: '$DESC19_A"; echo " $DESC19_B'" ;;
keyonly) echo "description:"; echo " $DESC19_A"; echo " $DESC19_B" ;;
*) echo "make_form_fixture: unknown form '$form'" >&2; exit 1 ;;
esac
echo "---"
echo ""
echo "Body."
} > "$dir/plugins/testplugin/skills/zzzskill/SKILL.md"
echo "$dir"
}
# Alert text with the `line:col` prefix and ANSI colouring stripped, sorted.
alert_text() {
echo "$1" \
| sed -E 's/\x1b\[[0-9;]*m//g' \
| grep -oE '(error|warning|suggestion)[[:space:]]+.*' \
| sed -E 's/[[:space:]]+/ /g' \
| sort
}
echo ""
echo "--- every multi-line description form reports what its single-line form reports ---"
FIXTURE19_SINGLE="$(make_form_fixture single)"
BASELINE19="$(alert_text "$(run_wrap "$FIXTURE19_SINGLE" --config "$VALE_CONFIG" "$REL_SKILL19")")"
if [[ -z "$BASELINE19" ]]; then
fail "the single-line baseline reported nothing — the comparison below would be vacuous"
fi
for FORM19 in folded plain dquote squote keyonly; do
DIR19="$(make_form_fixture "$FORM19")"
BARE19="$(cd "$DIR19" && vale --config "$VALE_CONFIG" "$REL_SKILL19" 2>&1 || true)"
GOT19="$(alert_text "$(run_wrap "$DIR19" --config "$VALE_CONFIG" "$REL_SKILL19")")"
if echo "$BARE19" | grep -q "VagueWording"; then
fail "bare vale already flags the $FORM19 form, so this case can't detect a silently-skipped flattening"
elif [[ "$GOT19" == "$BASELINE19" ]]; then
pass "a $FORM19 multi-line description reports the same alerts as its single-line form"
else
fail "a $FORM19 multi-line description diverged from its single-line form: got [$GOT19]"
fi
done
# --- 20. A style token containing an ASCII apostrophe matches inside a
# flattened description. The flattener used to substitute U+2019 for every `'`
# before writing the scratch copy, so no rule whose token carried an apostrophe
# could ever fire on a flattened description — a silent, rule-shaped blind spot.
# Both branches that can hold an apostrophe verbatim are exercised: a value that
# is safe unquoted, and one that must be quoted (it contains `: `) and so has to
# land in a double-quoted scalar, since a single-quoted one would need the `''`
# escape that kills the scope outright.
echo ""
echo "--- a style token containing an apostrophe matches in a flattened description ---"
APOS_STYLE="$(mktemp -d)"
new_fixture "$APOS_STYLE"
mkdir -p "$APOS_STYLE/styles/Apostrophe"
cat > "$APOS_STYLE/styles/Apostrophe/Token.yml" <<'EOF'
extends: existence
message: "apostrophe token: '%s'"
level: error
scope: text.frontmatter.description
ignorecase: true
tokens:
- "user's task"
EOF
cat > "$APOS_STYLE/.vale.ini" <<'EOF'
StylesPath = styles
[**/SKILL.md]
BasedOnStyles = Apostrophe
EOF
FIXTURE20_PLAIN="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Use when the user's task needs handling, and a second physical
line continues the folded scalar.
---
Body.
EOF
)"
new_fixture "$FIXTURE20_PLAIN"
FIXTURE20_QUOTED="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Triggers on: the user's task needing handling, and a second
physical line continues the folded scalar.
---
Body.
EOF
)"
new_fixture "$FIXTURE20_QUOTED"
for CASE20 in "unquoted:$FIXTURE20_PLAIN" "double-quoted:$FIXTURE20_QUOTED"; do
if run_wrap "${CASE20#*:}" --config "$APOS_STYLE/.vale.ini" "$REL_SKILL19" \
| grep -q "Apostrophe.Token"; then
pass "an apostrophe-bearing token matches in a flattened ${CASE20%%:*} description"
else
fail "an apostrophe-bearing token was rewritten out of a flattened ${CASE20%%:*} description"
fi
done
# --- 20b. The one combination no verbatim YAML scalar can carry — needs
# quoting, holds an apostrophe, and holds a double quote — falls back to the
# lossy U+2019 substitution. Apostrophe-bearing tokens are lost there by
# design, but the scope must stay alive so every other rule still fires.
echo ""
echo "--- the unrepresentable combination keeps the description scope alive ---"
FIXTURE20C="$(make_raw_fixture <<'EOF'
---
name: zzzskill
description: >
Triggers on: the user's "audit this" phrasing, which helps with
and utilize things across a second physical line.
---
Body.
EOF
)"
new_fixture "$FIXTURE20C"
if run_wrap "$FIXTURE20C" --config "$VALE_CONFIG" "$REL_SKILL19" | grep -q "VagueWording"; then
pass "a description needing quotes with both an apostrophe and a double quote is still linted"
else
fail "a description needing quotes with both an apostrophe and a double quote produced no alerts"
fi
# --- 21. A symlinked file inside a directory argument is mirrored and linted.
# Vale follows symlinks (both a symlinked file and a file under a symlinked
# directory), so a `-type f` walk of the tree reported "0 files" where bare vale
# reports one — and the audit skills read a "0 files" report as NOT RUN.
echo ""
echo "--- mirrors a symlinked file reached through a directory argument ---"
FIXTURE21="$(make_fixture 2)"
new_fixture "$FIXTURE21"
mkdir -p "$FIXTURE21/real"
mv "$FIXTURE21/$REL_SKILL19" "$FIXTURE21/real/SKILL.md"
ln -s ../../../../real/SKILL.md "$FIXTURE21/$REL_SKILL19"
BARE21="$(cd "$FIXTURE21" && vale --config "$VALE_CONFIG" plugins 2>&1 || true)"
WRAPPED21="$(run_wrap "$FIXTURE21" --config "$VALE_CONFIG" plugins)"
BARE21_FILES="$(echo "$BARE21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
WRAPPED21_FILES="$(echo "$WRAPPED21" | sed -E 's/\x1b\[[0-9;]*m//g' | grep -oE 'in [0-9]+ files?' | tail -1)"
if [[ "$BARE21_FILES" != "in 1 file" ]]; then
fail "bare vale did not lint the symlinked file ($BARE21_FILES), so this case can't detect the walk dropping it"
elif [[ "$WRAPPED21_FILES" != "$BARE21_FILES" ]]; then
fail "the directory walk dropped a symlinked file: wrapper saw '$WRAPPED21_FILES', bare vale '$BARE21_FILES'"
elif echo "$WRAPPED21" | grep -q "VagueWording"; then
pass "a symlinked file under a directory argument is mirrored, flattened and flagged"
else
fail "a symlinked file was mirrored but not flattened — no alert came back"
fi
# --- 22. The value of a separated two-argv flag is never treated as a lint
# target, however file-like it looks. `--output tmpl.tmpl` names a real
# template file: classifying it as input both linted the template and reordered
# argv, so vale received `--output --no-wrap` and died on `open :`.
echo ""
echo "--- a separated flag value that names a real file is not linted as a target ---"
FIXTURE22="$(make_fixture 1)"
new_fixture "$FIXTURE22"
printf 'TMPL{{range .Files}} {{.Path}}{{end}}\n' > "$FIXTURE22/tmpl.tmpl"
WRAPPED22="$(run_wrap "$FIXTURE22" --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19")"
BARE22="$(cd "$FIXTURE22" && vale --config "$VALE_CONFIG" --output tmpl.tmpl --no-wrap "$REL_SKILL19" 2>&1 || true)"
# The fixture's description is a single physical line, so flattening is a no-op
# and the two invocations must agree byte for byte.
if [[ "$WRAPPED22" == "$BARE22" ]]; then
pass "a separated --output value is passed through to vale, not linted"
else
fail "a separated --output value was misrouted: wrapper gave [$WRAPPED22], bare vale [$BARE22]"
fi
# --- 23. A path argument that does not exist is a hard error. Bare vale drops
# it, falls back to stdin and prints `0 errors ... in stdin` with exit 0, so a
# typo'd target is indistinguishable from a clean run — and the audit skills'
# NOT RUN guard string-matches `0 files`, which `in stdin` never produces. This
# is a deliberate divergence from bare vale, documented in the wrapper header.
echo ""
echo "--- a nonexistent path argument fails loudly instead of falling back to stdin ---"
set +e
OUT23="$(cd "$FIXTURE22" && bash "$SCRIPT" --config "$VALE_CONFIG" plugins/testplugin/skills/zzzskill/SKILLL.md 2>&1)"
RC23=$?
set -e
if [[ $RC23 -eq 0 ]]; then
fail "a typo'd path exited 0 — indistinguishable from a clean run, the bug this test guards against"
elif echo "$OUT23" | grep -q "in stdin"; then
fail "a typo'd path fell back to reading stdin and reported 'in stdin' instead of erroring"
elif echo "$OUT23" | grep -q "SKILLL.md"; then
pass "a typo'd path exits nonzero with a message naming the path"
else
fail "a typo'd path exited $RC23 but the message does not name it: $OUT23"
fi
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]]