Compare commits
4 Commits
1614bcef23
...
1ec3e8a1ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec3e8a1ea | |||
| c84f1f4145 | |||
| 384756b343 | |||
| 8cfd54f925 |
@@ -237,9 +237,11 @@ repos:
|
||||
entry: scripts/check-rtk-prefix.sh
|
||||
language: script
|
||||
files: '^plugins/[^/]+/\.apm/(skills/.*\.md|agents/.*\.agent\.md)$'
|
||||
# README.md is excluded on purpose, not by oversight. A skill-directory
|
||||
# README is consumer-facing prose that no agent ever loads, and the
|
||||
# `git clone` lines in the six tests/README.md files are setup
|
||||
# README.md is excluded on purpose, not by oversight. The 12 README.md
|
||||
# files still in scope sit in a skill's scripts/, tests/ and assets/
|
||||
# subdirectories -- consumer-facing prose that no agent ever loads (the
|
||||
# skill-directory READMEs this was first written for are deleted) -- and
|
||||
# the `git clone` lines in the six tests/README.md files are setup
|
||||
# instructions for a third party who has no rtk installed. Prefixing
|
||||
# those would be actively wrong -- see ADR-0023's consumer section.
|
||||
exclude: '(^|/)README\.md$'
|
||||
|
||||
12
CONTEXT.md
12
CONTEXT.md
@@ -30,10 +30,10 @@ _Avoid_: router body, thin body
|
||||
|
||||
**Hand-invoked skill**:
|
||||
A skill reached only by typing its slash command, declared `disable-model-invocation: true`. The host
|
||||
withholds it from the model-visible listing entirely, so it pays no preload tax and its description
|
||||
becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other skill can route to
|
||||
a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops working the moment `x`
|
||||
takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`.
|
||||
withholds it from the model-visible listing entirely, so it costs nothing in always-on context and
|
||||
its description becomes human-facing text. The flag also hard-blocks the Skill tool, so **no other
|
||||
skill can route to a hand-invoked skill** — a `` Call `x` `` step in another skill's body stops
|
||||
working the moment `x` takes the flag. Check inbound routes before declaring one. Exemplar: `zoom-out`.
|
||||
_Avoid_: manual skill, disabled skill
|
||||
|
||||
**Delegation discipline**:
|
||||
@@ -155,9 +155,9 @@ _Avoid_: namespace, category
|
||||
> **Dev:** "This one only fires when someone types the slash command. Does its description still need
|
||||
> trigger words?"
|
||||
> **Maintainer:** "No — that's a **hand-invoked skill**. The host withholds it from the model-visible
|
||||
> listing, so it pays no **preload tax** at all and the description is human-facing text."
|
||||
> listing, so it costs nothing in always-on context and the description is human-facing text."
|
||||
> **Dev:** "Then the body can be as long as it needs to be?"
|
||||
> **Maintainer:** "Different budget. The **skill context contract** gates the body whether or not the
|
||||
> **Maintainer:** "Different budget. ADR-0020's authoring rules gate the body whether or not the
|
||||
> skill is model-invoked — the description competes with every other skill's description, the body
|
||||
> competes with the caller's live conversation. Four mutually exclusive flows means a **dispatch
|
||||
> body**: table in `SKILL.md`, one `references/` file per flow."
|
||||
|
||||
@@ -12,7 +12,7 @@ Content ships as six installable plugins, each an apm (Agent Package Manager) pa
|
||||
| `providers/claude-code/` | Claude Code adapter, deployed to `~/.claude/` via `scripts/install.sh` |
|
||||
| `core/` | Provider-agnostic always-on content — `core/AGENTS.md` and `core/instructions/` |
|
||||
| `docs/` | Specs (`docs/spec/`), architectural decisions (`docs/adr/`), governance, research, and notes |
|
||||
| `scripts/` | Install, sync, and check scripts used by the git hooks |
|
||||
| `scripts/` | Install and check scripts used by the git hooks |
|
||||
| `tests/` | `run-tests.sh`, `run-bats.sh`, the `test-*.sh` suites, and the bats submodules |
|
||||
|
||||
The six plugins:
|
||||
@@ -86,7 +86,7 @@ pre-commit run --hook-stage pre-push --all-files
|
||||
|
||||
See [`docs/spec/gates.md`](docs/spec/gates.md) for what each hook enforces and why.
|
||||
|
||||
**Offline?** No pre-push hook needs the network: root `apm.yml`'s marketplace has no remote package entries (the last one, `mattpocock-skills`, was removed), so `apm-pack-check-clean` resolves everything from local sources. All pre-push hooks pass offline.
|
||||
**Offline?** No pre-push hook needs the network **once `apm install` has populated `apm_modules/`**. Root `apm.yml`'s marketplace has no remote package entries (the last one, `mattpocock-skills`, was removed), so `apm-pack-check-clean` resolves everything from local sources, and `apm-audit-ci`'s install-replay is cache-only against a populated install. On a **fresh clone** there is no cache: `apm-audit-ci`'s `deployed-files-present` fails outright, and its `drift` and `config-consistency` checks clone from the holocron remote. The offline guarantee is a property of a populated `apm_modules/`, not of the hook set — run `apm install` once on a new checkout and it holds from then on.
|
||||
|
||||
## Editing plugin content
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Plugin-scope agent-author omits `tools:` and all Claude-only fields from `.apm/agents/*.agent.md`
|
||||
|
||||
**Amended by ADR-0025 (2026-09-15).** `agent-audit` was removed and its flow merged with
|
||||
`skill-audit`'s into `factory-audit`, which dispatches to a skill flow and an agent flow at Step 0.
|
||||
Read `agent-audit` below as `factory-audit`'s agent flow, and `validate.sh` as that flow's
|
||||
validator. The decision is unchanged — plugin-scope `.apm/agents/*.agent.md` still carries only the
|
||||
allowlisted fields, and the allowlist is still read as data from a reference file, now
|
||||
`factory-audit/references/agent-field-inventory.md`. The present-tense skill names below are
|
||||
updated accordingly.
|
||||
|
||||
This ADR is a narrower, downstream consequence discovered while designing issue #89's
|
||||
implementation under ADR-0015's broader direction (Microsoft APM replaces hand-authored
|
||||
plugin/marketplace authoring). It does not restate ADR-0015's rationale — see that ADR for
|
||||
@@ -47,8 +55,8 @@ Absent `tools:` means inherit-all-tools on both harnesses — the one value that
|
||||
on either target, unlike a present, harness-specific value that is guaranteed wrong on at least
|
||||
one of them.
|
||||
|
||||
`agent-audit`, at plugin scope, is intended to flag — as a **SUGGESTION**, not a FAIL, since
|
||||
this is an upstream schema limitation rather than an authoring mistake — any agent whose
|
||||
`factory-audit`'s agent flow, at plugin scope, is intended to flag — as a **SUGGESTION**, not a
|
||||
FAIL, since this is an upstream schema limitation rather than an authoring mistake — any agent whose
|
||||
description or body implies a need for tool restriction or a Claude-only behavior the
|
||||
frontmatter can no longer express. This would give visibility into the gap without pretending
|
||||
the schema can do something it can't. **Not yet implemented**: `check_apm_agent_file()` in
|
||||
@@ -71,7 +79,7 @@ write Claude's space-separated `tools:` string. Rejected because it ships a valu
|
||||
silently wrong (or possibly a hard error) on Copilot, and which harness "wins" would be an
|
||||
arbitrary, undocumented asymmetry.
|
||||
|
||||
**Same as above, but `agent-audit` flags the cross-harness breakage as a tracked finding
|
||||
**Same as above, but `factory-audit` flags the cross-harness breakage as a tracked finding
|
||||
(rejected).** Rejected for the same core reason — it still ships a wrong value to a real
|
||||
harness. Tracking the breakage doesn't prevent it, and the chosen decision already gets
|
||||
equivalent visibility (a SUGGESTION finding) without ever shipping the wrong value in the first
|
||||
@@ -141,7 +149,7 @@ admitted as the portable-by-construction half of what was lost. It restores a re
|
||||
confirmed write fence against the tool-call path, not a complete write sandbox. The consequence
|
||||
below is narrowed accordingly.
|
||||
|
||||
Enforcement follows the decision: `agent-audit`'s plugin-scope validator reads its allowlist as
|
||||
Enforcement follows the decision: `factory-audit`'s plugin-scope validator reads its allowlist as
|
||||
data from the `apm-agent-allowlist` section of
|
||||
`plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md` (now
|
||||
`factory-audit/references/agent-field-inventory.md`, see ADR-0025), and that line now reads
|
||||
@@ -161,9 +169,9 @@ whether a field is safe under verbatim copy in a single vendor-neutral file.
|
||||
Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands in
|
||||
`<root>/.apm/agents/`." Project and user scope, and the rest of ADR-0005, are unaffected.
|
||||
- **ADR-0008 is partially superseded** — its counterpart-derivation/pair-validation mechanism
|
||||
no longer applies at plugin scope; `agent-audit` takes the single file directly there. Project
|
||||
no longer applies at plugin scope; `factory-audit` takes the single file directly there. Project
|
||||
and user scope, where a real pair still exists, are unaffected.
|
||||
- **ADR-0009 is not superseded.** The mechanism it established — `agent-audit` reading field
|
||||
- **ADR-0009 is not superseded.** The mechanism it established — `factory-audit` reading field
|
||||
lists from `references/field-inventory.md` (now
|
||||
`factory-audit/references/agent-field-inventory.md`, see ADR-0025) rather than hardcoding them,
|
||||
with a `source_keys`
|
||||
|
||||
@@ -85,12 +85,13 @@ question is only what catches a missed edit.
|
||||
> does not silently stop the hook deploying. Whether apm behaved this way when this ADR was written
|
||||
> was not established. **The decision stands** — `scripts/check-executables-allow-sync.sh` is now
|
||||
> justified by this repo's own requirement that the key track `plugins/kyberforge/apm.yml`'s
|
||||
> `version:`, not by an apm-level failure mode. `docs/spec/gates.md` carries the same correction. A comment in the `executables:` block is not enough:
|
||||
this repo gates generated-content drift, marketplace mirror drift and vale style drift
|
||||
deterministically, and a silent-staleness failure is strictly worse than any of them. So
|
||||
`scripts/check-executables-allow-sync.sh` runs at pre-push, parsing `version:` out of
|
||||
`plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the matching
|
||||
`kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
|
||||
> `version:`, not by an apm-level failure mode. `docs/spec/gates.md` carries the same correction.
|
||||
|
||||
A comment in the `executables:` block is not enough: this repo gates generated-content drift,
|
||||
marketplace mirror drift and vale style drift deterministically, and a silent-staleness failure is
|
||||
strictly worse than any of them. So `scripts/check-executables-allow-sync.sh` runs at pre-push,
|
||||
parsing `version:` out of `plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the
|
||||
matching `kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
|
||||
actually holds. It parses with PyYAML where importable and falls back to a two-shape scan
|
||||
otherwise, so a missing pip package cannot become the thing that blocks every push.
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ ADR-0015. The root `marketplace:` block in `apm.yml` and the compiled
|
||||
`.claude-plugin/marketplace.json` it produces are **kept** — see "Also delete the marketplace
|
||||
catalogue" under considered options.
|
||||
|
||||
**Amended by ADR-0025 (2026-09-15).** The decision stands unchanged — apm is the only supported
|
||||
install path, and `.apm/` still ships the per-skill `tests/` directories this ADR accepted as
|
||||
dev-fixture leakage. What moved is **consequence 2's skill count**. `skill-audit` and `agent-audit`
|
||||
merged into `factory-audit`, collapsing two `.bats`-carrying skill directories into one, so the same
|
||||
10 `.bats` files now deploy across **5** skills, not the six counted here on 2026-09-14. The figure
|
||||
below is corrected in place; "all six `apm.yml` files" in the same paragraph counts plugins, not
|
||||
skills, and is unaffected.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0018 moved this repo's own consumption of its own plugins onto `apm install`. From that point
|
||||
@@ -167,7 +175,7 @@ README note is the only available mitigation, and a note is not a gate.
|
||||
|
||||
**2. Consumers now receive dev-fixture files.** apm installs from `.apm/`, and `.apm/` contains the
|
||||
per-skill `tests/` directories the mirror explicitly stripped (ADR-0017's depth-scoped
|
||||
`<category>/<name>/tests` exclusion). 10 `.bats` files across 6 skills therefore now deploy into
|
||||
`<category>/<name>/tests` exclusion). 10 `.bats` files across 5 skills therefore now deploy into
|
||||
every consumer's skill directories. Suppressing them would mean switching all six `apm.yml` files
|
||||
from `includes: auto` to explicit include lists — and an explicit list that is wrong silently drops
|
||||
content, which is the same failure class ADR-0017 was written to fix. Trading a cosmetic problem for
|
||||
|
||||
@@ -117,7 +117,8 @@ This is the area you named as hardest to understand and slowest. Root cause: mos
|
||||
- [x] ~~`check-vale-style-sync`: 413 lines + 798 test lines guarding a byte-identical 526-line `vale-wrap.sh` and style directory copied between skill-audit and agent-audit. About 350 of its lines run Vale glob probes against the hook file patterns. Disappears if the two audit skills merge (finding 14); the probes belong in `test-vale-wrap.sh`.~~ **Done (2026-09-15, ~~`467bbd7`~~ → `620f20b`)** — hook, script and test all deleted; see the settled note below for the corrected probe arithmetic.
|
||||
- `check-scope-walkup-sync`: ~~365~~ → **381** lines (plus **297** test lines; re-measured 2026-09-16 at HEAD) cross-checking four independent ports of the same package-root walk-up. Disappears if the ports share one script ~~or the skills merge~~ — the second half is refuted below, and the first is unreachable.
|
||||
> **Grilled, held (2026-09-14):** both of the above are gated on findings 14/15 (merging skill-audit+agent-audit and skill-author+agent-author), deliberately held for a separate session rather than decided here. Correction for that session: the audit's §8 grouping is wrong — these merges don't need ADR-0012 revisited (that ADR governs the unrelated `core` plugin's three `agentsmd-*` skills). The actual constraint is ADR-0014 (no-cross-skill file sharing on plugin cache-install), and merging sidesteps it rather than requiring it be reversed. The open question for that session is a design one — a shared skill's `description` carrying both skill- and agent-audit trigger phrases — not an ADR supersession. ADR-0012 revisit is needed only for finding 24.
|
||||
> **Settled (2026-09-15) — split verdict, and the first bullet held in full.** Finding 14 landed as `factory-audit` (ADR-0025). **`check-vale-style-sync` is deleted**, hook, script and test, exactly as the first bullet predicted — and its probes **were** rehomed into `test-vale-wrap.sh`, as cases 28-30 (case 31 carries the override allowlist), so both halves of that bullet are closed. `docs/spec/gates.md` records the rehoming, not an open gap. *(Updated later on 2026-09-15.)* The one assertion this note used to call still uncovered — cross-manifest *agreement* between `.pre-commit-hooks.yaml`'s and `.pre-commit-config.yaml`'s `files:` regexes — ~~is now ported as case 33, which pairs the hooks by `id:`~~ → was ported as case 33, and case 33 was deleted with `.pre-commit-hooks.yaml` in `4de5b6b` (finding 36), so there is no second manifest left to agree with. Case 32 covers the separate zero-match question. It was a real gap while it lasted: narrowing the local skill hook to `^plugins/kyberforge/` left 6 of 38 skills prefiltered and the suite green. `bash tests/test-vale-wrap.sh` now reports ~~`61 passed, 0 failed`~~ → `63 passed, 0 failed` (it was 56 before cases 0 and 33 and the Part B mutation self-tests; 61 on 2026-09-15, and 63 once case 34 — the static `.vale.ini` style-load check — landed on 2026-09-16. Without vale on PATH it reports 19 and exits 77, up from 17). The bullet's "about 350 of its lines run Vale glob probes" overstates the probe half: at `a5962ba` the script is **413 lines**, of which the `.vale.ini` coverage section is **332** (`67..398`) and the machinery that actually invokes vale against a probe path is **204** (`195..398`). The balance of that section is `StylesPath`, `BasedOnStyles` and per-rule-override greps — text assertions, not probes. (Its test file is **797** lines, as the note above says, not the 798 the bullet carries.) **`check-scope-walkup-sync` stays**, and the second bullet's "or the skills merge" is wrong: two of its four walk-up ports are in the *author* skills (`new-agent.sh`, `new-skill.sh`), which this merge does not touch, and the audit-side pair is Python against the author-side pair's Bash, so the gate can never degrade into a text diff. Full reasoning in §10's 2026-09-15 note. Finding 15 would not remove it either.
|
||||
> **Settled (2026-09-15) — split verdict, and the first bullet held in full.** Finding 14 landed as `factory-audit` (ADR-0025). **`check-vale-style-sync` is deleted**, hook, script and test, exactly as the first bullet predicted — and its probes **were** rehomed into `test-vale-wrap.sh`, as cases 28-30 (case 31 carries the override allowlist), so both halves of that bullet are closed. `docs/spec/gates.md` records the rehoming, not an open gap. *(Updated later on 2026-09-15.)* The one assertion this note used to call still uncovered — cross-manifest *agreement* between `.pre-commit-hooks.yaml`'s and `.pre-commit-config.yaml`'s `files:` regexes — ~~is now ported as case 33, which pairs the hooks by `id:`~~ → was ported as case 33, and case 33 was deleted with `.pre-commit-hooks.yaml` in `4de5b6b` (finding 36), so there is no second manifest left to agree with. Case 32 covers the separate zero-match question. It was a real gap while it lasted: narrowing the local skill hook to `^plugins/kyberforge/` left 6 of 38 skills prefiltered and the suite green. `bash tests/test-vale-wrap.sh` reports ~~`61 passed, 0 failed`~~ → ~~`63 passed, 0 failed`~~ → **`65 passed, 0 failed` (pinned at `1614bce`)** (it was 56 before cases 0 and 33 and the Part B mutation self-tests; 61 on 2026-09-15, and 63 once case 34 — the static `.vale.ini` style-load check — landed on 2026-09-16. Without vale on PATH it reports ~~19~~ → **14** and exits 77, ~~up from 17~~). The bullet's "about 350 of its lines run Vale glob probes" overstates the probe half: at `a5962ba` the script is **413 lines**, of which the `.vale.ini` coverage section is **332** (`67..398`) and the machinery that actually invokes vale against a probe path is **204** (`195..398`). The balance of that section is `StylesPath`, `BasedOnStyles` and per-rule-override greps — text assertions, not probes. (Its test file is **797** lines, as the note above says, not the 798 the bullet carries.) **`check-scope-walkup-sync` stays**, and the second bullet's "or the skills merge" is wrong: two of its four walk-up ports are in the *author* skills (`new-agent.sh`, `new-skill.sh`), which this merge does not touch, and the audit-side pair is Python against the author-side pair's Bash, so the gate can never degrade into a text diff. Full reasoning in §10's 2026-09-15 note. Finding 15 would not remove it either.
|
||||
> > **Re-measured and pinned (2026-09-20, at `1614bce`).** The two `test-vale-wrap.sh` counts in the note above were written as current readings rather than pinned to a commit, and both went stale when `ea119d8` added cases to that suite after this note. Measured here, not copied forward: `bash tests/test-vale-wrap.sh` → `Results: 65 passed, 0 failed`, exit 0; `env PATH=/usr/bin:/bin bash tests/test-vale-wrap.sh` → `Results: 14 passed, 0 failed`, exit 77. The struck 63 and 19 were correct for the commits they were taken at; no attempt is made here to attribute the 19 → 14 move, only to record the reading at `1614bce`. Take the counts from a run against a named commit, never from this note — that is the same reason §1 carries its "Pinned (2026-09-16, review round)" note.
|
||||
- [x] ~~`check-marketplace-mirror-sync`: guards `.github/plugin/marketplace.json`. The script header calls it Copilot's legacy convention path and says Copilot also accepts the Claude path; the vendored Copilot docs list it as primary. Verify against current Copilot CLI before deleting hook, script, test, and mirror file.~~
|
||||
> **Grilled and done (2026-09-14):** verified against GitHub's current Copilot CLI plugin docs (not the vendored copy, which risked drift). Copilot CLI's marketplace discovery checks paths in order — `marketplace.json`, `.plugin/marketplace.json`, `.github/plugin/marketplace.json`, `.claude-plugin/marketplace.json` — falling through to whichever exists first. `.claude-plugin/marketplace.json` (apm's own `claude` output) already satisfies that chain's last step, so the dedicated `.github/plugin/marketplace.json` mirror bought Copilot users its *preferred* discovery path rather than a required one. Decided against reopening ADR-0018 (native install for both Claude Code and Copilot CLI stays supported) to justify this — the deletion holds either way, since Copilot's own fallback covers it. Deleted `.github/plugin/marketplace.json`, `scripts/sync-marketplace-mirror.sh` (81 lines), `tests/test-sync-marketplace-mirror.sh` (304 lines), and the `check-marketplace-mirror-sync` pre-push hook; removed the dangling references to the deleted script in `scripts/sync-plugin-content.sh` and `tests/test-sync-plugin-content.sh` (both had comments citing its reasoning by name), and updated `docs/spec/architecture.md`'s description of the marketplace-manifest compile step. `tests/test-sync-plugin-content.sh` (92 cases) still passes in full.
|
||||
>
|
||||
@@ -164,6 +165,7 @@ This is the area you named as hardest to understand and slowest. Root cause: mos
|
||||
Pre-commit stays roughly as is minus `skill-frontmatter`, and minus `check-ast` once finding 9 removes the only `.py` files. ~~Tests 26 files to about 10 (12,400 to about 5,000 lines).~~ Keep bats and its three submodules; the 351 bats tests ship inside plugins and are the right tool there. ~~Do not port the bash suites to bats; delete them instead.~~ **Struck (2026-09-16, grill):** see finding 8's closing note — the suites are regression coverage (findings 3 and 5; finding 16 found the same of the validators they test).
|
||||
|
||||
> **Re-measured (2026-09-14, at `a6434e0`):** the tests target was stated against the 2026-09-10 baseline and both its numbers are stale. `tests/` now holds **20 `test-*.sh` suites totalling 9,123 lines** (plus the two runners, 490). Six suites have gone since the baseline: `test-check-manifests.sh` (`e647f14`), `test-skill-frontmatter.sh` (`c8a7c9e`), `test-governance-layer.sh` and `test-instructions-and-docs.sh` (`5f9f2b3`), `test-sync-marketplace-mirror.sh` (`0dffff3`), `test-sync-plugin-content.sh` (`718c79a`). ~~Restated on the same basis the target is **20 files to about 10, 9,123 to about 5,000 lines**~~ — **struck (2026-09-16):** the target itself is withdrawn (see the struck sentence above); for the record, `tests/` holds **19** suites totalling **10,000** lines at `4b17703`, after `4de5b6b` deleted `test-check-release-needed.sh` and `test-vale-hooks-consumer.sh`. Finding 9's `check-ast` clause is moot anyway, since finding 9 is not proceeding.
|
||||
> > **Corrected (2026-09-20, at `1614bce`) — the deletion tally is nine, not ~~six~~ → ~~eight~~.** The six named above plus the two the 2026-09-16 strike adds come to eight, and a ninth was never folded into the running tally: **`test-check-vale-style-sync.sh`**, removed by `620f20b` with the `factory-audit` merge (finding 14) — the same commit finding 2's bullet already credits for deleting that gate's hook and script. The full `main...HEAD` set is nine: `test-check-manifests.sh` (`e647f14`), `test-check-release-needed.sh` (`4de5b6b`), `test-check-vale-style-sync.sh` (`620f20b`), `test-governance-layer.sh` and `test-instructions-and-docs.sh` (`5f9f2b3`), `test-skill-frontmatter.sh` (`c8a7c9e`), `test-sync-marketplace-mirror.sh` (`0dffff3`), `test-sync-plugin-content.sh` (`718c79a`), `test-vale-hooks-consumer.sh` (`4de5b6b`). Method: `git diff --name-status main...HEAD -- tests/ | grep '^D'`. The pinned "19 suites at `4b17703`" is unaffected — `620f20b` precedes that commit, so the file count already reflected the deletion even though the tally did not. At `1614bce` `tests/` holds **19** `test-*.sh` suites totalling **10,897** lines.
|
||||
|
||||
## 4. Plugins
|
||||
|
||||
@@ -328,6 +330,8 @@ The shared pattern: per-skill `README.md` files no model reads, a `docs/research
|
||||
> **The self-containment constraint does not support this finding the way it supports 14/15** — there is no cross-skill duplication here to merge away. `agentsmd-audit`'s three scripts share essentially nothing with `validate-adapter.sh` (no `read_text`, no BOM handling, no NUL check; they exit 1 on usage errors). Merging would *expose* that they are unhardened — costing lines, not saving them.
|
||||
>
|
||||
> Two further blockers if it were ever revisited: the merge dissolves `agentsmd-author`'s standing prohibition *"Never write to a provider file yourself, in any circumstance"* (SKILL.md:21), a hazard `c59e4bf` closed after the validator's own size-FAIL remediation text "actively invited the prohibited edit"; and `skill-size-check.sh:121` + `tests/test-skill-size-check.sh:729` both cite `a8cd5e8`'s exit-2 split as precedent for their own, so deleting it orphans two live cross-references.
|
||||
>
|
||||
> > **Corrected (2026-09-20, at `1614bce`) — neither cross-reference points at `a8cd5e8` any more, and one line number was wrong when written.** `e4ed343` ("docs(gates): cite the reachable squash commit for the exit-2 split") repointed both at `598a7c3`, which `main` reaches. The citations now sit at `scripts/skill-size-check.sh:121` and `tests/test-skill-size-check.sh:737` — `:737`, not the `:729` above. `grep -rn a8cd5e8 scripts/ tests/` returns nothing. The blocker itself is unaffected: the exit-2 precedent still exists, under a hash a branch reaches. Same correction as §12's follow-up, closed there on the same date.
|
||||
|
||||
25. [x] **lint: delete the `lint-runner` agent.** Its body is "call `vale-run`, reformat output", which `--output=JSON` already gives; it exists for backends that do not exist. It is the example boundary clause in three `agent-author` templates and ADR-0016, so those need a new example. About 40% of `vale-config` is install tables and settings lists the model can fetch from vale.sh. Keep the house-verified matrices (`E100`/`E201`, `Packages` below glob, frontmatter, ignore paths). `lint/docs/research/docs/vale/` overlaps the skill's own references by about two thirds. Effort S.
|
||||
> **Decision (2026-09-16):** Not proceeding — the human declined this finding. The `lint-runner` agent stays.
|
||||
@@ -654,8 +658,8 @@ Seven parallel reviewers went over the whole branch against `main`, each coverin
|
||||
|
||||
**Open follow-ups:**
|
||||
|
||||
- **Gitea #101.** Close it through this branch's PR with `Closes #101`. A comment is posted.
|
||||
- **~~Gitea #101.~~ Closed — the instruction was already a no-op when written (2026-09-20).** ~~Close it through this branch's PR with `Closes #101`.~~ A comment is posted. Read back from the Gitea API on 2026-09-20, #101 is `"state": "closed"` with `"closed_at": "2026-09-16T16:03:35Z"` — closed on 2026-09-16, this §12 note's own date, so there is nothing left for a `Closes #101` trailer to do. No PR change needed. The comment on the issue stands.
|
||||
- **Gitea #66.** It needs re-scoping, because its `.mcp.json` target is gone. A comment is posted.
|
||||
- **The dropped `LESSONS.md` entry.** The entry saying that "read at session start" is only a hope was removed, and no issue tracks it.
|
||||
- **The ADR-0020 constants.** They could move into the shared library that `skill-size-check` now sources, which would remove the last duplicated copy.
|
||||
- **The `a8cd5e8` citations.** `scripts/skill-size-check.sh` and `tests/test-skill-size-check.sh` still cite `a8cd5e8`, which no branch reaches. `598a7c3` is the reachable equivalent.
|
||||
- **~~The `a8cd5e8` citations.~~ Closed (2026-09-20, by `e4ed343`).** ~~`scripts/skill-size-check.sh` and `tests/test-skill-size-check.sh` still cite `a8cd5e8`, which no branch reaches. `598a7c3` is the reachable equivalent.~~ `e4ed343` ("docs(gates): cite the reachable squash commit for the exit-2 split", 2026-09-16 15:39 UTC) landed after this follow-up was written and repointed both at `598a7c3`: `scripts/skill-size-check.sh:121` and `tests/test-skill-size-check.sh:737`. Verified at `1614bce` — `grep -rn a8cd5e8 scripts/ tests/` returns nothing. §4.4's finding 24 note carried the same stale citation (with `:729`, a wrong line number) and is corrected in place there.
|
||||
|
||||
@@ -57,8 +57,9 @@ Eight hooks, grouped below by what they guard rather than by the order `.pre-com
|
||||
| `check-scope-walkup-sync` | `validate.sh`, `validate-provenance.sh`, `new-agent.sh` and `new-skill.sh`'s four independent `$HOME`/`.git`/`apm.yml` walk-up ports still agree behaviorally |
|
||||
| `check-executables-allow-sync` | root `apm.yml`'s `executables.allow` key names kyberforge's actual version (see [apm gates](#apm-gates)) |
|
||||
|
||||
`check-executables-allow-sync` is the odd one in this group: it guards a *silent failure* rather than
|
||||
drift in generated text.
|
||||
`check-executables-allow-sync` is the odd one in this group: the drift it guards is in a
|
||||
hand-written key rather than in generated text, and it is a record-keeping gate — the grant itself is
|
||||
version-blind, so a stale key deploys fine (see [apm gates](#apm-gates)).
|
||||
|
||||
**Artifact validators**
|
||||
|
||||
@@ -105,7 +106,10 @@ ADR-0022 makes `metadata.version` mandatory and says a skill change carries a bu
|
||||
version at the tip of the same `main` ref (ADR-0022's second 2026-09-16 amendment). The tip
|
||||
check stops two branches that make the same bump (`1.0.0` → `1.0.1`) with different content from
|
||||
both landing, since the identical version lines merge without a conflict. A skill absent at the
|
||||
tip is held to the merge-base alone; when `main` has not moved, the two are the same commit. Each
|
||||
tip is held to the merge-base alone, and so is one whose directory at the pushed commit is the
|
||||
*same tree object* as at the tip: it ships exactly what main ships, whatever route the history
|
||||
took there — a criss-cross merge, a cherry-pick, a backport — so there is nothing for a bump to
|
||||
announce. When `main` has not moved, the two baselines are the same commit. Each
|
||||
failure line names the baseline it missed: `(not above merge-base)` or
|
||||
`(not above origin/main tip)`. The tip is `origin/main` as last fetched.
|
||||
- **It fails closed when it has no trustworthy baseline:** neither `origin/main` nor `main`
|
||||
@@ -634,8 +638,10 @@ boundary, and a stricter form would only move the same trust to a different stri
|
||||
- **Prose bullets.** Most of `branch-operations.md`, `merging.md` and `rewrite-history.md` instruct
|
||||
in list items, not fences. Those are clause-1 sites the gate cannot see, because it cannot
|
||||
distinguish them from clause-2 mentions in the same list.
|
||||
- **`README.md`, excluded by pattern.** A skill-directory README is consumer-facing prose no agent
|
||||
loads, and the `git clone https://github.com/bats-core/…` lines in the six `tests/README.md`
|
||||
- **`README.md`, excluded by pattern.** The skill-directory READMEs the exclusion was first written
|
||||
for are deleted; what it still covers is the 12 `README.md` files inside a skill's `scripts/`,
|
||||
`tests/` and `assets/` subdirectories — consumer-facing prose no agent loads — and the
|
||||
`git clone https://github.com/bats-core/…` lines in the six `tests/README.md`
|
||||
files are setup instructions for a third party who has no `rtk`. Prefixing those would be actively
|
||||
wrong, not merely noisy — see ADR-0023's consumer section.
|
||||
- **Quoting.** The line splitter breaks on `;`, `|`, `&&`, `||`, `$(` and backticks without tracking
|
||||
@@ -939,8 +945,8 @@ pass, and a skip fails the push.
|
||||
|
||||
`test-vale-wrap.sh` without Vale skips only its Vale-dependent cases, not the whole suite. The cases
|
||||
that are plain greps and awk over the Vale config and `.pre-commit-config.yaml` still run: case 0, 16, 26,
|
||||
27, the static half of 28, 31 Parts A and B, 32 and 34. A static failure exits 1, because a
|
||||
real defect is not a setup error. Only an all-static-pass run exits 77.
|
||||
27, the static half of 28, 31 Parts A and B, 32, 34 and the static half of 35. A static failure
|
||||
exits 1, because a real defect is not a setup error. Only an all-static-pass run exits 77.
|
||||
|
||||
### Mentioning banned phrasing without tripping the rule
|
||||
|
||||
@@ -1010,9 +1016,10 @@ near-miss negatives it must leave alone, and the suite from 5 cases to **7**.
|
||||
an `echo` or `printf` feeding any of them is the same race. Those are guarded by **convention** —
|
||||
absorb the writer's status with `|| true`, or take the verdict from a here-string — and deliberately
|
||||
not by this test: most legitimate uses of them in this tree are already absorbed, and the scanner
|
||||
cannot see absorption from the pipeline text alone, so flagging them would be noise. Two live
|
||||
`grep … | head -1` sites (`tests/test-vale-wrap.sh:620` and `:1046`) were fixed by hand with that
|
||||
idiom. Pipes from a non-builtin writer (`run_wrap … | grep -q`) are out of scope for the same reason:
|
||||
cannot see absorption from the pipeline text alone, so flagging them would be noise. The three live
|
||||
`grep … | head -1` sites in `tests/test-vale-wrap.sh` — in `unguarded_expansions()`, in case 20B's
|
||||
`--output line` line-number read, and in case 28's per-file `RESULTS28` lookup — carry that idiom by
|
||||
hand. Pipes from a non-builtin writer (`run_wrap … | grep -q`) are out of scope for the same reason:
|
||||
in practice they either absorb the writer's exit status with `|| true` or write only once, at exit.
|
||||
|
||||
**Known limitation: heredoc bodies are scanned as code.** A `cat <<'EOF'` body containing a
|
||||
|
||||
@@ -6,7 +6,7 @@ description: >
|
||||
Not read-only review -> `factory-audit`. Not skills -> `skill-author`.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
version: "1.0.2"
|
||||
version: "1.0.3"
|
||||
category: factory
|
||||
source_keys:
|
||||
- context7-websites-code-claude
|
||||
|
||||
@@ -29,7 +29,8 @@ A description carries exactly three things:
|
||||
3. **Boundary clause** — form: `Not <thing> -> <name>.` Add one only where a near-miss agent or
|
||||
skill could steal delegations.
|
||||
|
||||
Banned from a description; move it to the body or to `README.md`:
|
||||
Banned from a description; move it to the body — an agent is a single file with no `references/`
|
||||
directory to move it to:
|
||||
|
||||
- Capability enumeration or feature lists
|
||||
- Per-scope emission mechanics — which files the author skill writes at which scope changes no
|
||||
|
||||
@@ -7,7 +7,7 @@ description: >
|
||||
fixes -> agent-author.
|
||||
allowed-tools: Bash Read
|
||||
metadata:
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
category: factory
|
||||
source_keys:
|
||||
- agentskills-home
|
||||
|
||||
@@ -44,7 +44,8 @@ A plugin-scope agent is a single `.apm/agents/<name>.agent.md` file with no sibl
|
||||
directory. It cannot progressively disclose to itself — it can only delegate to skills. So a
|
||||
procedure spelled out in an agent body that a skill the agent invokes already owns is not a
|
||||
shortcut: it is a second copy of that procedure, and the second copy drifts. This is the
|
||||
characteristic agent defect, the way a stale README row is the characteristic skill defect.
|
||||
characteristic agent defect, the way a `SKILL.md` naming a `references/` file that is not there is
|
||||
the characteristic skill defect.
|
||||
|
||||
**An agent body that restates a procedure owned by a skill it can invoke is a FAIL.** The Fix is
|
||||
always the same shape: invoke `<skill>` instead.
|
||||
|
||||
@@ -46,7 +46,7 @@ A model-invoked description carries exactly three things:
|
||||
instead") is only a SUGGESTION unless a second target in the same sentence resolves. Take the
|
||||
script's tier as given and report it once, under Structure.
|
||||
|
||||
Everything else belongs in the body or in `README.md`.
|
||||
Everything else belongs in the body or in a `references/` file.
|
||||
|
||||
## Indirect triggers — conditional, never blanket
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: >
|
||||
Not read-only review -> `factory-audit`. Not agent files -> `agent-author`.
|
||||
allowed-tools: Bash Read Write Edit
|
||||
metadata:
|
||||
version: "1.0.3"
|
||||
version: "1.0.4"
|
||||
category: factory
|
||||
source_keys:
|
||||
- agentskills-home
|
||||
|
||||
@@ -7,9 +7,9 @@ source_keys:
|
||||
|
||||
# The description and body contract
|
||||
|
||||
House contract. Every rule here is enforced by `/factory-audit` —
|
||||
`scripts/validate.sh` for the counts and the boundary targets, the bundled Vale styles for the
|
||||
prose patterns, and its reference files for the judgment calls.
|
||||
House contract. Every rule here is enforced by `/factory-audit` — the counts and the boundary
|
||||
targets by `factory-audit`'s `scripts/validate.sh`, the prose patterns by the Vale styles it
|
||||
bundles, the judgment calls by its reference files.
|
||||
|
||||
## Why the budget exists
|
||||
|
||||
@@ -28,10 +28,11 @@ A description carries exactly three things:
|
||||
Focus on user intent, not the skill's internal mechanics.
|
||||
2. **At most one capability clause** — what it does, one clause, no enumeration. Be specific
|
||||
("parses and validates OpenAPI specs", not "helps with APIs").
|
||||
3. **Boundary clause** — form: `Not <thing> -> <skill-name>.` Add one only where a near-miss skill
|
||||
could steal activations.
|
||||
3. **Boundary clause** — form: `Not <thing> -> <skill-name>.` Write one per genuine near-miss
|
||||
skill that could steal activations: at least one, not exactly one — `git-remotes` carries
|
||||
four. What is banned is a clause invented for a skill that was never going to compete.
|
||||
|
||||
Banned from a description; move it to the body or to `README.md`:
|
||||
Banned from a description; move it to the body or to a `references/` file:
|
||||
|
||||
- Capability enumeration or feature lists
|
||||
- Output-format detail ("Produces a compact findings report with Why and Fix per finding")
|
||||
|
||||
@@ -58,7 +58,7 @@ Then proceed — edits are reversible via git, no approval checkpoint needed.
|
||||
## Step 4 — Apply changes
|
||||
|
||||
Edit any file in the skill directory that the signals point to: SKILL.md, `scripts/`,
|
||||
`references/`, `assets/`, `tests/`, README.md.
|
||||
`references/`, `assets/`, `tests/`.
|
||||
|
||||
**Generalize, do not patch.** Find the underlying gap, not the specific example that failed. A fix
|
||||
scoped only to the test cases you have seen will overfit and perform worse on new inputs.
|
||||
@@ -89,6 +89,16 @@ order, stopping once the gate clears; the order puts the cuts that lose the leas
|
||||
|
||||
Still over after all four means the skill does two jobs: split it rather than compressing prose.
|
||||
|
||||
**Re-cite what moved.** After content moves between files, update `references/sources.md`'s
|
||||
`Contributing files` for every slug whose content moved, and drop any file the edit deleted.
|
||||
`factory-audit`'s `scripts/validate-provenance.sh` exits 0 on exactly that drift, so a stale
|
||||
provenance claim ships unless you fix it here.
|
||||
|
||||
**Re-check every relocated gate's reachability.** A Gotcha or gate moved out of the body into one
|
||||
flow's `references/` file is invisible to every other branch, and the word counts improve either
|
||||
way. For each one you move, list the flows that need it: it belongs in one flow's file only when
|
||||
exactly one flow reaches it, otherwise in the body's common-gates section.
|
||||
|
||||
If a signal points to a script or reference file, edit that file directly rather than adding a
|
||||
workaround in SKILL.md.
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Fails the push when root apm.yml's executables.allow key stops naming
|
||||
# kyberforge's actual version. apm matches that key by exact dict lookup
|
||||
# (apm_cli/security/executables.py) — a version bump that misses the key
|
||||
# update deploys nothing, with no error anywhere. See ADR-0019, "The allow
|
||||
# key is version-pinned, and that is a live failure mode", for the full
|
||||
# argument; nothing else in the pre-push gate compares these two files.
|
||||
# kyberforge's actual version. The grant itself is version-blind — apm matches
|
||||
# the version-less name alongside '<package>#<version>' (exec_gate.py builds
|
||||
# the candidate list, _map_grants matches it) — so a stale key keeps granting
|
||||
# kyberforge's hooks/ and bin/ and deployment does not break. What this gate
|
||||
# buys is repo-level, not apm-level: a version bump without the matching key
|
||||
# edit fails this repo's own pre-push, so the key stays an accurate record of
|
||||
# what was approved. See ADR-0019's 2026-09-19 correction and docs/spec/gates.md,
|
||||
# "check-executables-allow-sync"; nothing else in the pre-push gate compares
|
||||
# these two files.
|
||||
#
|
||||
# Run from repo root or pass REPO_ROOT as arg.
|
||||
|
||||
@@ -213,10 +217,10 @@ if [[ -n "$STALE_KEYS" ]]; then
|
||||
echo " Found instead:" >&2
|
||||
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
|
||||
fi
|
||||
echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2
|
||||
echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2
|
||||
echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2
|
||||
echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2
|
||||
echo " Why: nothing is broken right now — apm's grant is version-blind, so a key naming another" >&2
|
||||
echo " version still grants kyberforge's hooks/ and bin/. This gate is a repo-level record" >&2
|
||||
echo " check: a version bump without the matching key edit fails here, which is what keeps" >&2
|
||||
echo " the key an accurate record of what was approved (ADR-0019, 2026-09-19 correction)." >&2
|
||||
echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2
|
||||
echo " plugins/kyberforge/apm.yml is not complete without it." >&2
|
||||
exit 1
|
||||
|
||||
@@ -36,7 +36,11 @@ set -euo pipefail
|
||||
# without a conflict, so the merge-base alone would let main ship both under
|
||||
# one version. When <main> has not moved since the merge-base, the two
|
||||
# baselines are one commit and the skill is checked once. The tip is read as
|
||||
# last fetched.
|
||||
# last fetched. A skill whose directory at the pushed commit is the SAME TREE
|
||||
# OBJECT as at the tip skips the tip comparison: it ships exactly what main
|
||||
# ships, so there is nothing to announce. The merge-base intersection catches
|
||||
# that only when some base carries the content — true of the criss-cross shape
|
||||
# above, false of a branch that cherry-picks a fix main already has.
|
||||
#
|
||||
# Pushing main itself: with origin/main as the baseline, a push of main diffs
|
||||
# the new commits against what the remote already has, so it is covered. A
|
||||
@@ -281,6 +285,17 @@ in_tree() {
|
||||
git rev-parse --verify -q "$1:$2" > /dev/null
|
||||
}
|
||||
|
||||
# same_subtree <commit-a> <commit-b> <path>: both name <path> with the same
|
||||
# object, so the two commits ship byte-identical content there. Compared as
|
||||
# object ids rather than by diffing: a tree id is the content, whatever route
|
||||
# the history took to it. A path missing on either side is not a match.
|
||||
same_subtree() {
|
||||
local a b
|
||||
a="$(git rev-parse --verify -q "$1:$3")" || return 1
|
||||
b="$(git rev-parse --verify -q "$2:$3")" || return 1
|
||||
[[ "$a" == "$b" ]]
|
||||
}
|
||||
|
||||
OFFENDERS=()
|
||||
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
# Bases the skill exists at, in merge-base order, with the version read at
|
||||
@@ -306,7 +321,18 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md"; then
|
||||
# A skill byte-identical to the tip's copy of it is already what main ships,
|
||||
# so there is nothing left for a bump to announce. The merge-base rule alone
|
||||
# would already have exempted it, but only when a base carries that same
|
||||
# content — which a criss-cross history gives and a linear one does not. A
|
||||
# branch cut before a fix landed on main and then cherry-picking that fix has
|
||||
# one merge-base, predating the fix, so the skill counts as changed against
|
||||
# it and reaches the tip comparison carrying exactly the tip's version. Same
|
||||
# content, same version, and the only escapes would be a spurious bump —
|
||||
# leaving main carrying two versions of identical content — or a rebase the
|
||||
# push does not otherwise need.
|
||||
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" \
|
||||
&& ! same_subtree "$MAIN_TIP" "$PUSHED_COMMIT" "$dir"; then
|
||||
at_tip=true
|
||||
fi
|
||||
# Absent at every baseline: new, renamed-to, or merged-into. Exempt.
|
||||
@@ -317,15 +343,22 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
|
||||
tip_ver=""
|
||||
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
|
||||
|
||||
# The baseline the two messages below name. A skill added on main after the
|
||||
# branch was cut is absent at every merge-base, so no base names a version
|
||||
# while the tip does — and the tip's is the version the push is actually held
|
||||
# to. Reporting "none" there sends the author to the natural 1.0.0 and costs
|
||||
# them a second blocked push on the same mistake.
|
||||
report_ver="${first_base_ver:-$tip_ver}"
|
||||
|
||||
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
|
||||
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${first_base_ver:-none})")
|
||||
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${report_ver:-none})")
|
||||
continue
|
||||
fi
|
||||
version_at "$PUSHED_COMMIT" "$dir/SKILL.md"
|
||||
cur_ver="$VERSION"
|
||||
|
||||
if [[ -z "$cur_ver" ]]; then
|
||||
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${first_base_ver:-none})")
|
||||
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${report_ver:-none})")
|
||||
continue
|
||||
fi
|
||||
# Named by sha only when there is more than one base to tell apart; a
|
||||
|
||||
@@ -20,5 +20,3 @@ DEPLOY_EXECUTABLES=(
|
||||
DEPLOY_DIRS=(
|
||||
"core:.claude/core"
|
||||
)
|
||||
|
||||
# Provider skill adapters are declared in providers/*/provider-manifest.sh, not here.
|
||||
|
||||
@@ -180,6 +180,15 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
# file bats really did run, so it has to be distinguishable from a file that
|
||||
# produced nothing whatsoever.
|
||||
file_plan="$(grep -c '^1\.\.[0-9]' "$SCRATCH_ROOT/$i.log" || true)"
|
||||
# The planned count itself, extracted only when the file emitted exactly one
|
||||
# plan line. Zero plans is the broken-harness case the aggregate guard below
|
||||
# names, and two or more means the log is not one file's TAP stream at all --
|
||||
# in neither case does "the planned count" mean anything, so the per-file
|
||||
# comparison is skipped and the previous behaviour stands.
|
||||
file_planned=""
|
||||
if [[ "$file_plan" -eq 1 ]]; then
|
||||
file_planned="$(sed -n 's/^1\.\.\([0-9][0-9]*\).*$/\1/p' "$SCRATCH_ROOT/$i.log")"
|
||||
fi
|
||||
# String-compared below, not `-ne`. `-ne` is arithmetic and bash evaluates an
|
||||
# empty string as 0 there -- `[[ "" -ne 0 ]]` is false -- so an *empty* status
|
||||
# file read as a clean exit. The `|| echo 1` fallback only covers a *missing*
|
||||
@@ -189,14 +198,28 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
TOTAL_OK=$((TOTAL_OK + file_ok))
|
||||
TOTAL_NOT_OK=$((TOTAL_NOT_OK + file_not_ok))
|
||||
TOTAL_PLANS=$((TOTAL_PLANS + file_plan))
|
||||
# Two independent failure signals, deliberately OR-ed: a file can report `not
|
||||
# ok` lines while its process still exits 0 (a bats formatter or wrapper that
|
||||
# swallows the status), and a file can exit non-zero having emitted no `not
|
||||
# ok` at all (a crash, a timeout, an unbound variable in setup_file). Real
|
||||
# bats normally emits both at once, so each signal masks the other and
|
||||
# dropping either half is invisible without tests that produce one without
|
||||
# the other -- tests/test-run-bats.sh has those.
|
||||
if [[ "$file_not_ok" -gt 0 || "$status" != "0" ]]; then
|
||||
# Three independent failure signals, deliberately OR-ed: a file can report
|
||||
# `not ok` lines while its process still exits 0 (a bats formatter or wrapper
|
||||
# that swallows the status), a file can exit non-zero having emitted no `not
|
||||
# ok` at all (a crash, a timeout, an unbound variable in setup_file), and a
|
||||
# file can emit FEWER results than its own plan line promised. Real bats
|
||||
# normally emits all three consistently, so each signal masks the others and
|
||||
# dropping any one of them is invisible without tests that produce one without
|
||||
# the rest -- tests/test-run-bats.sh has those.
|
||||
#
|
||||
# The plan is the third signal and it is now enforced, not merely counted. It
|
||||
# is the one that survives precisely the wrapper-swallows-the-status case
|
||||
# named above: a process printing `1..10`, three `ok` lines and exit 0 used to
|
||||
# be counted as "3 tests, 0 failures" and go green with seven tests silently
|
||||
# gone, because the plan was computed for the aggregate zero-count guard below
|
||||
# and then discarded. Mismatch either way is a failure -- more results than
|
||||
# planned is as broken a TAP stream as fewer.
|
||||
file_short=false
|
||||
if [[ -n "$file_planned" && $((file_ok + file_not_ok)) -ne "$file_planned" ]]; then
|
||||
file_short=true
|
||||
echo "Error: $rel planned $file_planned test(s) but emitted $((file_ok + file_not_ok)) result line(s) — the run was truncated, or its exit status was swallowed" >&2
|
||||
fi
|
||||
if [[ "$file_not_ok" -gt 0 || "$status" != "0" || "$file_short" == true ]]; then
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
# passing hook, and a SUGGESTION deliberately does not fail, so dropping
|
||||
# one word from the config silences the tier ADR-0020 depends on while
|
||||
# every test and every hook still reports green.
|
||||
# 4. The WIRING of every repo-authored hook — its id, its `entry:` and its
|
||||
# `stages:`. Same failure shape as 3, one level up: the tests drive these
|
||||
# scripts by path, so nothing noticed whether .pre-commit-config.yaml
|
||||
# still invoked them. On a scratch copy, deleting eight local hook blocks
|
||||
# at once — `run-tests` among them — left every suite green. Upstream
|
||||
# hooks and every `rev:` are deliberately out of scope.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
@@ -680,6 +686,199 @@ while IFS=$'\t' read -r status msg; do
|
||||
fi
|
||||
done <<< "$VERBOSE_REPORT"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Every repo-authored hook is still WIRED, at the stage it claims
|
||||
# ---------------------------------------------------------------------------
|
||||
# Same failure mode as assertion 3, one level up. Every gate this repo owns is
|
||||
# driven in tests by PATH -- tests/test-skill-version-bump.sh runs
|
||||
# scripts/check-skill-version-bump.sh directly -- so the script staying correct
|
||||
# and the hook still existing are independent facts, and only the first one was
|
||||
# pinned. Deleting the `run-tests` block from .pre-commit-config.yaml (a routine
|
||||
# merge-conflict casualty) stops the entire suite from running at pre-push
|
||||
# forever, and `bash tests/run-tests.sh --strict` still prints all green: the
|
||||
# suite cannot notice that nothing invokes it. Verified on a scratch copy --
|
||||
# eight repo-authored hook blocks were deleted at once and every suite stayed
|
||||
# green.
|
||||
#
|
||||
# So: id + entry + stages, for the repo-authored hooks only. "Repo-authored"
|
||||
# is derived from the config (every hook under a `repo: local` entry), not
|
||||
# hardcoded, and the derived set is then compared against the expected list
|
||||
# below -- a new local hook that nobody pinned is itself a failure.
|
||||
#
|
||||
# Deliberately NOT pinned: the stock upstream hooks (gitleaks, check-yaml,
|
||||
# pretty-format-json, shellcheck, conventional-pre-commit, the `repo: meta`
|
||||
# pair) and every `rev:`. Those are somebody else's contract; a rev bump must
|
||||
# not churn this test.
|
||||
echo ""
|
||||
echo "--- every repo-authored hook is wired, with the entry and stages it claims ---"
|
||||
WIRING_REPORT="$(python3 - "$REPO_ROOT" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
root = sys.argv[1]
|
||||
|
||||
|
||||
def emit(status, msg):
|
||||
print("%s\t%s" % (status, msg))
|
||||
|
||||
|
||||
# The expected wiring of every hook this repo authors: id -> (entry, stages).
|
||||
# ADDING OR REMOVING A REPO-AUTHORED HOOK DELIBERATELY REQUIRES EDITING THIS
|
||||
# LIST. That is the point -- the list is the second party to the agreement, so a
|
||||
# hook cannot leave .pre-commit-config.yaml without someone saying so here.
|
||||
# Upstream hooks are absent on purpose and must stay absent.
|
||||
EXPECTED = {
|
||||
# pre-push
|
||||
'run-tests': (
|
||||
'bash tests/run-tests.sh --strict', ['pre-push']),
|
||||
'check-executables-allow-sync': (
|
||||
'bash scripts/check-executables-allow-sync.sh', ['pre-push']),
|
||||
'apm-audit-ci': (
|
||||
'bash -c \'for d in . plugins/*/; do (cd "$d" && apm audit --ci) || '
|
||||
'{ echo "apm audit --ci failed in $d" >&2; exit 1; }; done\'',
|
||||
['pre-push']),
|
||||
'check-apm-agents-valid': (
|
||||
'bash scripts/check-apm-agents-valid.sh', ['pre-push']),
|
||||
'apm-pack-check-clean': (
|
||||
'apm pack --check-versions --check-clean --dry-run', ['pre-push']),
|
||||
'check-scope-walkup-sync': (
|
||||
'bash scripts/check-scope-walkup-sync.sh', ['pre-push']),
|
||||
'check-skill-version-bump': (
|
||||
'bash scripts/check-skill-version-bump.sh', ['pre-push']),
|
||||
'validate-marketplace': (
|
||||
'claude plugin validate --strict .claude-plugin/marketplace.json',
|
||||
['pre-push']),
|
||||
# pre-commit
|
||||
'skill-size-check': (
|
||||
'scripts/skill-size-check.sh', ['pre-commit']),
|
||||
'check-rtk-prefix': (
|
||||
'scripts/check-rtk-prefix.sh', ['pre-commit']),
|
||||
'vale-audit-prefilter-skill': (
|
||||
'plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh',
|
||||
['pre-commit']),
|
||||
'vale-audit-prefilter-agent': (
|
||||
'plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh',
|
||||
['pre-commit']),
|
||||
}
|
||||
|
||||
path = os.path.join(root, '.pre-commit-config.yaml')
|
||||
try:
|
||||
with open(path, encoding='utf-8') as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
except Exception as exc:
|
||||
emit('FAIL', '.pre-commit-config.yaml did not parse: %s' % exc)
|
||||
sys.exit(0)
|
||||
|
||||
local = {}
|
||||
duplicates = []
|
||||
for repo in cfg.get('repos') or []:
|
||||
if repo.get('repo') != 'local':
|
||||
continue
|
||||
for hook in (repo.get('hooks') or []):
|
||||
hid = hook.get('id')
|
||||
if hid in local:
|
||||
duplicates.append(hid)
|
||||
local[hid] = hook
|
||||
|
||||
# Non-vacuity, first. Every assertion below iterates the derived set, so an
|
||||
# empty one would report nothing and the section would pass having checked
|
||||
# nothing at all -- the same shape of silent hole this whole file exists to
|
||||
# close.
|
||||
if not EXPECTED:
|
||||
emit('FAIL', 'the expected repo-authored hook list is empty — '
|
||||
'every wiring assertion below is vacuous')
|
||||
sys.exit(0)
|
||||
if not local:
|
||||
emit('FAIL', '.pre-commit-config.yaml declares no `repo: local` hooks at '
|
||||
'all — every gate this repo authors has been unwired, or the '
|
||||
'config moved and this assertion is now measuring nothing')
|
||||
sys.exit(0)
|
||||
emit('PASS', '.pre-commit-config.yaml declares %d repo-authored (`repo: local`) '
|
||||
'hooks — the wiring assertions below are not vacuous' % len(local))
|
||||
|
||||
if duplicates:
|
||||
emit('FAIL', '.pre-commit-config.yaml declares duplicate local hook ids '
|
||||
'(%s) — pre-commit runs one of them and the other is dead '
|
||||
'config' % ', '.join(sorted(set(duplicates))))
|
||||
|
||||
# The set, before the per-hook detail: a deleted block shows up here as a
|
||||
# missing id even if nothing else in the file changed.
|
||||
missing = sorted(set(EXPECTED) - set(local))
|
||||
extra = sorted(set(local) - set(EXPECTED))
|
||||
if missing or extra:
|
||||
parts = []
|
||||
if missing:
|
||||
parts.append('NOT WIRED in .pre-commit-config.yaml: %s' % ', '.join(missing))
|
||||
if extra:
|
||||
parts.append('wired but not pinned in this test: %s' % ', '.join(extra))
|
||||
emit('FAIL', 'the repo-authored hook set has drifted — %s. A hook that '
|
||||
'leaves the config stops running at push time while every '
|
||||
'test stays green; a hook that arrives unpinned can leave '
|
||||
'again unnoticed. Fix the config, or update EXPECTED in '
|
||||
'tests/test-adr0020-contract.sh deliberately.' % '; '.join(parts))
|
||||
else:
|
||||
emit('PASS', '.pre-commit-config.yaml wires exactly the %d expected '
|
||||
'repo-authored hooks, no more and no fewer' % len(EXPECTED))
|
||||
|
||||
# Per hook: the entry that runs and the stage it runs at. Both are single
|
||||
# tokens whose loss is invisible -- an entry repointed at a path that no longer
|
||||
# exists makes pre-commit fail loudly, but an entry repointed at a DIFFERENT
|
||||
# real script does not, and a hook moved off pre-push simply never fires.
|
||||
entry_paths = []
|
||||
for hid in sorted(EXPECTED):
|
||||
hook = local.get(hid)
|
||||
if hook is None:
|
||||
continue # already reported as missing above
|
||||
exp_entry, exp_stages = EXPECTED[hid]
|
||||
got_entry = hook.get('entry')
|
||||
got_stages = hook.get('stages')
|
||||
problems = []
|
||||
if got_entry != exp_entry:
|
||||
problems.append('entry is %r, expected %r' % (got_entry, exp_entry))
|
||||
if list(got_stages or []) != exp_stages:
|
||||
problems.append('stages is %r, expected %r — a hook at the wrong stage '
|
||||
'(or at none) never fires' % (got_stages, exp_stages))
|
||||
if problems:
|
||||
emit('FAIL', 'hook %s: %s' % (hid, '; '.join(problems)))
|
||||
else:
|
||||
emit('PASS', 'hook %s runs `%s` at %s'
|
||||
% (hid, exp_entry, ','.join(exp_stages)))
|
||||
# Collect the in-repo script/manifest paths the pinned entry names, so the
|
||||
# pin cannot agree with a config that points at nothing.
|
||||
for token in re.split(r'\s+', exp_entry):
|
||||
token = token.strip('\'"')
|
||||
if re.fullmatch(r'[A-Za-z0-9_.\-/]+\.(sh|json)', token):
|
||||
entry_paths.append((hid, token))
|
||||
|
||||
# Every pinned entry path exists on disk. Without this, EXPECTED and the config
|
||||
# could agree perfectly on a script that was deleted.
|
||||
if not entry_paths:
|
||||
emit('FAIL', 'no pinned entry named an in-repo script or manifest — the '
|
||||
'existence check below iterated zero times and proved nothing')
|
||||
else:
|
||||
broken = ['%s -> %s' % (hid, token)
|
||||
for hid, token in entry_paths
|
||||
if not os.path.exists(os.path.join(root, token))]
|
||||
if broken:
|
||||
emit('FAIL', 'pinned hook entries point at files that do not exist: %s'
|
||||
% ', '.join(broken))
|
||||
else:
|
||||
emit('PASS', 'all %d in-repo files named by a pinned hook entry exist '
|
||||
'on disk' % len(entry_paths))
|
||||
PY
|
||||
)"
|
||||
while IFS=$'\t' read -r status msg; do
|
||||
[[ -n "$status" ]] || continue
|
||||
if [[ "$status" == PASS ]]; then
|
||||
pass "$msg"
|
||||
else
|
||||
fail "$msg"
|
||||
fi
|
||||
done <<< "$WIRING_REPORT"
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -204,7 +204,14 @@ while IFS= read -r rel; do
|
||||
[[ -f "$REPO_ROOT/$rel" ]] && FILES+=("$REPO_ROOT/$rel")
|
||||
done < <(git -C "$REPO_ROOT" ls-files -- '*.sh' '*.bats' '*.bash')
|
||||
|
||||
if [[ "${#FILES[@]}" -lt 20 ]]; then
|
||||
# Floored at 50 against a real 57 (58 tracked `*.sh`/`*.bats`/`*.bash` files, less
|
||||
# this one, which excludes itself above). Same discipline as the per-glob floors in
|
||||
# tests/test-vale-wrap.sh: a guard against a broken `git ls-files` invocation or a
|
||||
# moved search root resolving to a fraction of the tree, not a headcount to keep in
|
||||
# step. Seven files of slack is one deliberate multi-file deletion -- a plugin's
|
||||
# whole tests/ directory is three or four .bats files -- and nowhere near enough to
|
||||
# absorb a discovery that degraded to a single directory.
|
||||
if [[ "${#FILES[@]}" -lt 50 ]]; then
|
||||
fail "only ${#FILES[@]} tracked shell files found — the scan is looking in the wrong place"
|
||||
else
|
||||
LIVE_HITS="$(scan ${FILES[@]+"${FILES[@]}"})"
|
||||
|
||||
@@ -444,6 +444,38 @@ else
|
||||
pass "a root under .claude/worktrees/ runs its own files and skips nested worktrees"
|
||||
fi
|
||||
|
||||
# --- 11. A file that emits fewer results than its own plan promised fails the
|
||||
# run. This is the third aggregation signal, and the only one left standing in
|
||||
# exactly the case run-bats.sh's own comment puts in its threat model: a bats
|
||||
# formatter or wrapper that swallows the exit status. A process printing `1..10`,
|
||||
# three `ok` lines and exiting 0 emits no `not ok` and no non-zero status, so both
|
||||
# other halves stay silent -- the plan was already being computed for the
|
||||
# aggregate zero-count guard and was then thrown away, so the run was counted as
|
||||
# "6 tests, 0 failures" and went green with fourteen tests silently gone.
|
||||
echo ""
|
||||
echo "--- a file emitting fewer results than its plan fails the run ---"
|
||||
DIR11="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR11")
|
||||
seed_bats_files "$DIR11"
|
||||
install_stub_bats "$DIR11" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..10"
|
||||
echo "ok 1 first"
|
||||
echo "ok 2 second"
|
||||
echo "ok 3 third"
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR11"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a file delivering 3 of its 10 planned tests exited 0 — a truncated run reported as a pass"
|
||||
elif ! grep -q "planned 10 test(s) but emitted 3 result line(s)" <<< "$FAKE_OUT"; then
|
||||
fail "the run failed but not with the plan-shortfall message: $FAKE_OUT"
|
||||
elif grep -q "^6 tests, 0 failures$" <<< "$FAKE_OUT"; then
|
||||
pass "a plan promising more tests than were delivered fails the run and names the shortfall"
|
||||
else
|
||||
fail "the plan-shortfall run failed with the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -633,6 +633,77 @@ write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F" F3
|
||||
expect_fail "an unbumped edit on a criss-cross branch still fails, naming the baseline sha" \
|
||||
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above merge-base [0-9a-f]{40}\)" "$F"
|
||||
|
||||
echo ""
|
||||
echo "--- 41. a cherry-picked fix identical to main's tip passes ---"
|
||||
# Case 40 pins the same property — content identical to main's tip ships
|
||||
# nothing — for a criss-cross history, where the merge-base intersection alone
|
||||
# already exempts the skill because one base carries that content. This is the
|
||||
# LINEAR shape, where no base does: the branch was cut before the fix landed on
|
||||
# main and then cherry-picked it, so the single merge-base predates the fix and
|
||||
# the skill reaches the tip comparison carrying exactly the tip's version. The
|
||||
# only escapes would be a spurious 1.0.2 — leaving main with two versions of
|
||||
# identical content — or a rebase the push does not otherwise need.
|
||||
#
|
||||
# C0 alpha 1.0.0
|
||||
# +-- main: FIX bumps alpha to 1.0.1 (origin/main)
|
||||
# +-- feature: cherry-picks FIX
|
||||
F="$(make_fixture)"
|
||||
(cd "$F" && git checkout -q main)
|
||||
write_skill "$F" demo alpha 'version: "1.0.1"' "fixed body"; commit "$F" "fix alpha"
|
||||
FIX_SHA="$(cd "$F" && git rev-parse HEAD)"
|
||||
# -x: without it the picked commit can come out byte-identical to FIX — same
|
||||
# tree, same parent, same author and committer second — and git reuses the sha,
|
||||
# so the branch silently fast-forwards onto main and the case under test is
|
||||
# gone. The trailer -x adds guarantees a distinct commit.
|
||||
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature \
|
||||
&& git cherry-pick -x "$FIX_SHA" > /dev/null)
|
||||
if [[ "$(cd "$F" && git rev-parse HEAD)" != "$FIX_SHA" ]]; then
|
||||
pass "fixture check: the cherry-pick made a distinct commit, not a fast-forward onto main"
|
||||
else
|
||||
fail "fixture check: the branch fast-forwarded onto main, so the tip is the merge-base"
|
||||
fi
|
||||
if [[ "$(cd "$F" && git merge-base --all origin/main HEAD | wc -l)" -eq 1 ]]; then
|
||||
pass "fixture check: the linear history has exactly one merge-base"
|
||||
else
|
||||
fail "fixture check: expected one merge-base, got $(cd "$F" && git merge-base --all origin/main HEAD)"
|
||||
fi
|
||||
if [[ -z "$(cd "$F" && git diff origin/main HEAD -- plugins)" ]]; then
|
||||
pass "fixture check: nothing under plugins/ differs between origin/main and the branch"
|
||||
else
|
||||
fail "fixture check: plugins/ differs, so this is not the case under test"
|
||||
fi
|
||||
expect_pass "a cherry-picked skill identical to main's tip passes without a further bump" "$F"
|
||||
# The ratchet still holds on the same shape: a further edit is no longer
|
||||
# identical to the tip, so the tip rule applies again.
|
||||
write_skill "$F" demo alpha 'version: "1.0.1"' "later body"; commit "$F"
|
||||
expect_fail "an unbumped edit on top of the cherry-pick still fails against the tip" \
|
||||
"alpha: 1\.0\.1 -> 1\.0\.1 \(not above origin/main tip\)" "$F"
|
||||
|
||||
echo ""
|
||||
echo "--- 42. the named baseline is the tip when no merge-base carries the skill ---"
|
||||
# A skill added on main after the branch was cut is absent at every merge-base,
|
||||
# so only the tip names a version — and the tip's is the version the push is
|
||||
# held to. Reporting "none" sends the author to the natural 1.0.0 and costs a
|
||||
# second blocked push on the same mistake.
|
||||
F="$(make_fixture)"
|
||||
(cd "$F" && git checkout -q main)
|
||||
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
|
||||
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
|
||||
write_skill "$F" demo delta "" "branch delta"; commit "$F" "add delta on branch"
|
||||
expect_fail "a missing version names the tip's version, not 'none'" \
|
||||
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: 3\.2\.1\)" "$F"
|
||||
expect_fail "the baseline is never reported as none while the tip carries one" \
|
||||
"delta: metadata\.version missing or not MAJOR\.MINOR\.PATCH at HEAD \(baseline: [0-9]" "$F"
|
||||
# The same message shape for the SKILL.md-missing branch of the report.
|
||||
F="$(make_fixture)"
|
||||
(cd "$F" && git checkout -q main)
|
||||
write_skill "$F" demo delta 'version: "3.2.1"' "main's delta"; commit "$F" "add delta on main"
|
||||
(cd "$F" && git update-ref refs/remotes/origin/main main && git checkout -q feature)
|
||||
mkdir -p "$F/plugins/demo/.apm/skills/delta/references"
|
||||
echo "ref" > "$F/plugins/demo/.apm/skills/delta/references/x.md"; commit "$F" "delta without SKILL.md"
|
||||
expect_fail "a missing SKILL.md names the tip's version too" \
|
||||
"delta: SKILL\.md missing at HEAD \(baseline: 3\.2\.1\)" "$F"
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for scripts/vale-wrap.sh: Vale's `text.frontmatter.description`
|
||||
# Regression test for plugins/kyberforge/.apm/skills/factory-audit/scripts/vale-wrap.sh: Vale's `text.frontmatter.description`
|
||||
# NLP scope silently stops matching when the description value is a YAML block
|
||||
# scalar spanning 2+ physical lines. vale-wrap.sh flattens it to one line before
|
||||
# handing off to the real vale binary — this asserts that actually happens.
|
||||
|
||||
Reference in New Issue
Block a user