bmad-module-skill-forge 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/404.md +4 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/RELEASING.md +7 -4
  5. package/docs/agents.md +2 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/bmad-synergy.md +13 -13
  8. package/docs/campaign.md +3 -3
  9. package/docs/concepts.md +3 -3
  10. package/docs/examples.md +9 -9
  11. package/docs/forge-auto.md +6 -6
  12. package/docs/getting-started.md +12 -12
  13. package/docs/how-it-works.md +7 -7
  14. package/docs/index.md +7 -1
  15. package/docs/skill-model.md +2 -2
  16. package/docs/troubleshooting.md +7 -7
  17. package/docs/why-skf.md +8 -8
  18. package/docs/workflows.md +6 -6
  19. package/package.json +7 -3
  20. package/src/shared/health-check.md +40 -3
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  22. package/src/shared/scripts/skf-detect-language.py +20 -0
  23. package/src/shared/scripts/skf-detect-tools.py +34 -0
  24. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  25. package/src/shared/scripts/skf-language-corpora.py +20 -0
  26. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  27. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  28. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  29. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  30. package/src/shared/scripts/skf-shape-detect.py +20 -0
  31. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  32. package/src/skf-analyze-source/references/recommend.md +1 -1
  33. package/src/skf-audit-skill/references/re-index.md +1 -1
  34. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  35. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  36. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  37. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  38. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  39. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  40. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  41. package/src/skf-create-skill/references/compile.md +12 -3
  42. package/src/skf-create-skill/references/extract.md +1 -1
  43. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  44. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  45. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  46. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  47. package/src/skf-setup/references/ccc-index.md +2 -2
  48. package/src/skf-setup/references/report.md +1 -1
  49. package/src/skf-test-skill/references/coverage-check.md +11 -5
  50. package/src/skf-test-skill/references/score.md +7 -1
  51. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  52. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  53. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  54. package/tools/cli/lib/installer.js +31 -1
  55. package/tools/validate-docs-links.js +665 -0
package/docs/why-skf.md CHANGED
@@ -44,7 +44,7 @@ For docs-only skills, the audit shape is the same — `provenance-map.json` stil
44
44
 
45
45
  If it doesn't, **that's a bug.** [Open an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) and SKF republishes the skill with a new commit SHA and a new provenance map. No other AI-skills tool treats disagreement between claim and source as a defect. SKF does.
46
46
 
47
- See the [Verifying a Skill](../verifying-a-skill/) page for the full three-step audit on real skills, the test reports that log *exactly* where coverage falls short, and the scoring formula behind the 80% pass threshold.
47
+ See the [Verifying a Skill](/docs/verifying-a-skill.md) page for the full three-step audit on real skills, the test reports that log *exactly* where coverage falls short, and the scoring formula behind the 80% pass threshold.
48
48
 
49
49
  ---
50
50
 
@@ -53,27 +53,27 @@ See the [Verifying a Skill](../verifying-a-skill/) page for the full three-step
53
53
  ### The curious developer
54
54
 
55
55
  Your agent just hallucinated a method that doesn't exist, again. You want this to stop, and you don't want to read a long architecture reference before running your first command.
56
- → Start with [Getting Started](../getting-started/).
56
+ → Start with [Getting Started](/docs/getting-started.md).
57
57
 
58
58
  ### The BMAD user
59
59
 
60
60
  You already use BMAD Method, BMM phases, TEA, or BMB, and you want to know where SKF fits.
61
- → Read [BMAD Synergy](../bmad-synergy/) for the phase-by-phase integration playbook.
61
+ → Read [BMAD Synergy](/docs/bmad-synergy.md) for the phase-by-phase integration playbook.
62
62
 
63
63
  ### The skeptic
64
64
 
65
65
  "AI docs for AI" sounds like the problem pretending to be the solution. You want receipts before you install anything.
66
- → Start with [Verifying a Skill](../verifying-a-skill/) — the three-step audit on real skills, including the 1% that fails.
66
+ → Start with [Verifying a Skill](/docs/verifying-a-skill.md) — the three-step audit on real skills, including the 1% that fails.
67
67
 
68
68
  ### The OSS maintainer
69
69
 
70
70
  You want to ship verified skills alongside your library releases — `npx skills publish`-ready, drift-detectable, version-pinned.
71
- → See [Examples → OSS Maintainer Publishing Official Skills](../examples/#scenario-h-oss-maintainer-publishing-official-skills).
71
+ → See [Examples → OSS Maintainer Publishing Official Skills](/docs/examples.md#scenario-h-oss-maintainer-publishing-official-skills).
72
72
 
73
73
  ### The team lead evaluating adoption
74
74
 
75
75
  You're considering running SKF across a brownfield platform. You need to know about rollback safety, `[MANUAL]` section preservation, and the health-check feedback loop before committing.
76
- → Start with [Architecture](../architecture/), then [Workflows → Workflow Health Check](../workflows/#terminal-step-health-check).
76
+ → Start with [Architecture](/docs/architecture.md), then [Workflows → Workflow Health Check](/docs/workflows.md#terminal-step-health-check).
77
77
 
78
78
  ---
79
79
 
@@ -89,6 +89,6 @@ Everything else is downstream of one question: *are the instructions your AI rea
89
89
 
90
90
  ## Next
91
91
 
92
- - **[Install SKF](../getting-started/#install)** — Node ≥ 22, Python ≥ 3.10, `uv`, one `npx` command
93
- - **[Audit a skill in 60 seconds](../verifying-a-skill/)** — see the receipts before you install
92
+ - **[Install SKF](/docs/getting-started.md#install)** — Node ≥ 22, Python ≥ 3.10, `uv`, one `npx` command
93
+ - **[Audit a skill in 60 seconds](/docs/verifying-a-skill.md)** — see the receipts before you install
94
94
  - **[Browse real skills](https://github.com/armelhbobdad/oh-my-skills)** — four Deep-tier skills, all shipping their audit trails
package/docs/workflows.md CHANGED
@@ -3,9 +3,9 @@ title: Workflows
3
3
  description: All 15 SKF workflows with commands, steps, and connection diagram
4
4
  ---
5
5
 
6
- Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../concepts/) for definitions.
6
+ Trigger workflows by typing commands to [Ferris](/docs/agents.md). See [Concepts](/docs/concepts.md) for definitions.
7
7
 
8
- > Already using BMAD? See [BMAD Synergy](../bmad-synergy/) for when to invoke each SKF workflow during BMM phases and alongside TEA, BMB, and GDS.
8
+ > Already using BMAD? See [BMAD Synergy](/docs/bmad-synergy.md) for when to invoke each SKF workflow during BMM phases and alongside TEA, BMB, and GDS.
9
9
 
10
10
  ---
11
11
 
@@ -83,7 +83,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
83
83
 
84
84
  **Purpose:** Brief-less fast skill with package-to-repo resolution.
85
85
 
86
- **Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](../skill-model/).
86
+ **Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](/docs/skill-model.md).
87
87
 
88
88
  **When to Use:** When you need a skill quickly — no brief needed. Accepts package names or GitHub URLs. Append `@version` to target a specific version (e.g., `@Ferris QS cognee@1.0.0`).
89
89
 
@@ -164,7 +164,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
164
164
 
165
165
  **Key Steps:** Load skill → Detect mode → Coverage check → Coherence check → External validation (skill-check, tessl) → Hard gate → Score → Gap report
166
166
 
167
- **Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](../verifying-a-skill/#how-the-score-is-computed) for the full formula and tier adjustments.
167
+ **Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](/docs/verifying-a-skill.md#how-the-score-is-computed) for the full formula and tier adjustments.
168
168
 
169
169
  **Agent:** Ferris (Audit mode)
170
170
 
@@ -315,7 +315,7 @@ flowchart TD
315
315
  EXPORT --> MAINT[Maintenance + Campaign Report]
316
316
  ```
317
317
 
318
- > **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](../campaign/). Clear your context between workflows for best results — or use pipeline mode to chain them automatically. See [Pipeline Mode](#pipeline-mode) below.
318
+ > **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](/docs/campaign.md). Clear your context between workflows for best results — or use pipeline mode to chain them automatically. See [Pipeline Mode](#pipeline-mode) below.
319
319
 
320
320
  ---
321
321
 
@@ -474,7 +474,7 @@ If the answer to all of these is "no", the health check exits in one line (`Clea
474
474
 
475
475
  - **Severity gate.** Only `bug` findings submit live as GitHub issues by default. `friction` and `gap` findings — the most subjective categories — go to a **local queue** at `{forge_data_folder}/improvement-queue/` unless you explicitly opt in to submit them live during the review gate. This keeps the high-signal reports (real defects) flowing to maintainers while the softer observations sit safely on your disk for you to batch or revisit.
476
476
  - **Fingerprint dedup.** Every finding gets a deterministic 7-hex fingerprint computed from `sha1(severity|workflow|step_file|section)` — no LLM similarity judgment, just a tuple hash. Before Ferris opens a new issue, it searches the repo for an existing open issue with the same `fp-*` label. If one exists, you're offered a choice: add a 👍 reaction (silent upvote), react + post a one-sentence environment delta, open a new issue anyway (if you're certain it's distinct), or skip. Re-reporting the same fingerprint is safe — it just adds to the signal-count on the canonical issue.
477
- - **Global seen-cache.** Once you've submitted or reacted for a given fingerprint, it's recorded at `~/.skf/health-check-seen.json` so the same user never re-reports the same defect across sessions or across different projects on the same machine.
477
+ - **Global seen-cache.** Once you've submitted or reacted for a given fingerprint, it's recorded at `~/.skf/health-check-seen.json` so the same user doesn't re-report the same defect across sessions or across different projects on the same machine, for as long as the issue it points at stays open. Once that issue is closed as completed, the suppression lifts on purpose: a fresh sighting of a fixed defect is a regression, and it gets reported and linked back to the original.
478
478
  - **Server-side safety net.** If two users race past the client-side search and both open issues with the same fingerprint, a GitHub Action on this repo catches it: the later issue is auto-closed as a duplicate, linked to the canonical (lowest-numbered) issue, and a 👍 is added there to preserve the signal-count. Manual filers using the [issue template](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) feed the same pipeline.
479
479
 
480
480
  **Net effect:** 1,000 users hitting the same bug produce **one canonical issue** with a reaction-count of roughly 1,000 — not 1,000 duplicate issues or a 1,000-comment thread. The maintainer sees population impact at a glance, and your report is never lost.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-module-skill-forge",
4
- "version": "2.0.1",
4
+ "version": "2.1.0",
5
5
  "description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
6
6
  "keywords": [
7
7
  "bmad",
@@ -44,18 +44,21 @@
44
44
  "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
45
45
  "lint:md": "markdownlint-cli2 \"**/*.md\"",
46
46
  "prepare": "husky || exit 0",
47
- "quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run docs:validate-drift",
47
+ "quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run docs:validate-drift",
48
48
  "skf:install": "node tools/cli/skf-cli.js install",
49
49
  "skf:status": "node tools/cli/skf-cli.js status",
50
50
  "skf:uninstall": "node tools/cli/skf-cli.js uninstall",
51
51
  "skf:update": "node tools/cli/skf-cli.js update",
52
- "test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run lint && npm run lint:md && npm run format:check",
52
+ "test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run lint && npm run lint:md && npm run format:check",
53
53
  "test:cli": "node test/test-cli-integration.js",
54
+ "test:docs-links-tool": "node test/test-validate-docs-links.js",
54
55
  "test:install": "node test/test-installation-components.js",
55
56
  "test:knowledge": "node test/test-knowledge-base.js",
56
57
  "test:python": "uv run --with pytest --with pyyaml --with jsonschema pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-atomic-write.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py test/test-skf-language-corpora.py test/test-skf-merge-doc-urls.py test/test-skf-derive-assembly-shape.py test/test-skf-description-guard.py test/test-skf-detect-scripts-assets.py test/test-skf-hash-content.py test/test-skf-validate-brief-schema.py test/test-skf-check-workspace-drift.py test/test-skf-update-active-symlink.py test/test-skf-build-change-manifest.py test/test-skf-provenance-gap-dispatch.py test/test-skf-resolve-authoritative-files.py test/test-skf-scan-manifests.py test/test-skf-pair-intersect.py test/test-skf-enumerate-stack-skills.py test/test-skf-compare-file-hashes.py test/test-skf-load-provenance.py test/test-skf-scan-skill-md-structure.py test/test-skf-disqualify-candidates.py test/test-skf-chain-reachability.py test/test-skf-shape-detect.py test/test-skf-detect-docs.py test/test-skf-step-doc-sources.py test/test-skf-step-doc-drift.py test/test-skf-step-hard-gate.py test/test-skf-per-pipeline-thresholds.py test/test-skf-evidence-report-fallback.py test/test-skf-validate-pins.py test/test-skf-auto-shard.py test/test-skf-preapply.py test/test-skf-step-doc-rot.py test/test-skf-campaign-state.py test/test-skf-campaign-pins.py test/test-skf-campaign-deps.py test/test-skf-campaign-report.py test/test-skf-campaign-stepfiles.py test/test-skf-campaign-validate-state.py test/test-skf-campaign-provenance.py test/test-skf-campaign-parse-manifest.py test/test-skf-campaign-render-kickoff.py test/test-skf-campaign-render-batch.py test/test-skf-shard-body.py test/test-skf-render-metadata-stats.py test/test-skf-comention-pairs.py test/test-skf-count-tokens.py test/test-skf-reconcile-coverage.py test/test-skf-verify-provenance-completeness.py test/test-skf-find-cycles.py test/test-skf-validate-feasibility-report.py test/test-skf-rewrite-skill-name.py test/test-skf-verify-no-trace.py test/test-skf-validate-inventory.py test/test-skf-check-metadata-coherence.py test/test-skf-scan-doc-rot.py test/test-skf-aggregate-coherence.py test/test-skf-dir-sizes.py test/test-skf-parse-pipeline.py test/test-skf-validate-rename-name.py test/test-skf-verify-declared-numerator.py test/test-skf-combine-external-scores.py test/test-skf-new-file-diff.py test/test-skf-coverage-tally.py test/test-skf-report-delta.py test/test-skf-verdict-rollup.py -v",
58
+ "test:rehype": "node test/test-rehype-markdown-links.js",
57
59
  "test:schemas": "node test/test-agent-schema.js",
58
60
  "test:workflow": "node test/test-workflow-state.js",
61
+ "validate:docs-links": "node tools/validate-docs-links.js --strict",
59
62
  "validate:refs": "node tools/validate-file-refs.js --strict",
60
63
  "validate:schemas": "node test/validate-agent-schema.js",
61
64
  "validate:skills": "node tools/validate-skills.js --strict"
@@ -111,6 +114,7 @@
111
114
  "prettier-plugin-packagejson": "^2.5.19",
112
115
  "semver": "^7.6.3",
113
116
  "sharp": "^0.33.5",
117
+ "unist-util-visit": "^5.1.0",
114
118
  "wrap-ansi": "^7.0.0",
115
119
  "xml2js": "^0.6.2",
116
120
  "yaml": "^2.7.0",
@@ -151,7 +151,36 @@ The `section-slug` is a kebab-case normalized section heading (e.g. `missing-sta
151
151
 
152
152
  **2. Check the local seen-cache** at `{seenCachePath}`:
153
153
 
154
- If the cache exists and already contains this fingerprint for this user, skip submission silently and log: `"fp-{hash}: already submitted from this machine on {date}, issue {url} — skipping"`. This prevents the same user from re-reporting the same defect across sessions.
154
+ Read `{seenCachePath}`. A missing file, an empty file, or one that does not parse as JSON is an empty cache — never an error, never a reason to halt.
155
+
156
+ **Presence in the cache is not the verdict.** A fingerprint whose defect was already fixed must NOT suppress: a fresh sighting of a fixed defect is a *regression*, and it is the single most valuable report this loop can produce. Look up the fingerprint and work these branches in order, stopping at the first that matches:
157
+
158
+ 1. **No record for this fingerprint** → `unseen`. Go to sub-step 3.
159
+ 2. **Record with `"action": "resolved"`** → `regression`. Recorded by hand when the defect was fixed. Go to sub-step 3 and apply the regression treatment below.
160
+ 3. **Record with `"action"` of `created`, `reacted`, or `commented`, and a non-empty `issue_url`** → you must decide whether that issue is still live before you may suppress. The cache is machine-local and records only what *this* user did; the issue's own state is shared, so it is the better authority. Run exactly this, and give it a hard time bound so a hung network can never strand the run:
161
+
162
+ ```
163
+ timeout 10 gh issue view {issue_url} --json state,stateReason --jq '.state + "/" + (.stateReason // "")'
164
+ ```
165
+
166
+ - `CLOSED/COMPLETED`, or `MERGED` (the record points at a resolving PR) → `regression`. It was fixed and closed out.
167
+ - `CLOSED/NOT_PLANNED` → `handled`. **Not** a fix: this is how the dedup Action closes a duplicate, and how a maintainer closes a wontfix. Treating it as a regression would file a REGRESSION issue against a defect nobody ever fixed, every session, forever.
168
+ - `OPEN` → `handled`. Already filed and still being tracked.
169
+ - Any failure at all (offline, timeout, no auth, deleted issue, unparseable output) → `handled`. This refinement never blocks a run and never halts on error.
170
+ 4. **Record with one of those actions but an empty `issue_url`** → `handled`. There is no issue to consult, so the local record stands.
171
+ 5. **Record with no `action`, or any other value** → `unrecognized`. Hand-edited, or written by a newer version of this file, so it cannot be confirmed handled. Go to sub-step 3 as an ordinary submission.
172
+
173
+ **`handled` is the only verdict that suppresses.** Skip submission silently and log: `"fp-{hash}: already handled on {date}, issue {url} — skipping"`. Every other verdict proceeds to sub-step 3.
174
+
175
+ **Why anything uncertain reports instead of suppressing.** A duplicate report is self-healing: the dedup Action closes it and moves the upvote to the canonical issue. A silenced regression is caught by nothing at all. When the verdict is in doubt, report.
176
+
177
+ **Regression treatment** (verdicts `regression` only). Carry the prior record forward so the recurrence can never be filed as a first sighting:
178
+
179
+ - Append ` — REGRESSION` to the issue title, after `{short description}`.
180
+ - Add a `## Regression` section immediately above `## Finding`, containing one line: `Regression of {issue_url}, resolved {date}.` If the prior record has no `issue_url`, write `Regression of a previously resolved local finding, resolved {date}.` The `{fp}` label still ties the two together server-side.
181
+ - Leave `## Finding` and the other one-sentence sections exactly as specified below. The backlink lives in its own section precisely so those budgets are untouched.
182
+
183
+ **What this does and does not catch.** The fingerprint is `sha1` over the section slug, so a fix that renames or relocates the section it touches changes the fingerprint. Such a regression arrives as `unseen` and is reported as a first sighting, which is safe but unlinked. These branches catch the regressions that recur under the same heading in the same step file, which is the case where the old behaviour was silent.
155
184
 
156
185
  **3. Check GitHub CLI availability** with `gh auth status`. If `gh` is unavailable, fall through to step 5c (local fallback).
157
186
 
@@ -205,9 +234,11 @@ gh issue create \
205
234
  --body "{formatted body using issue template structure}"
206
235
  ```
207
236
 
237
+ When sub-step 2 returned `regression`, append ` — REGRESSION` to that `--title` value, after `{short description}`, and include the `## Regression` backlink section in the body. The command above is the literal one you run, so the marker has to be in it: an instruction that the title should say REGRESSION loses to a copied command that does not.
238
+
208
239
  The fingerprint `{fp}` appears in both title (human-readable) and label (server-side filterable). Maintainers can query all reports for a defect via the `fp-*` label without relying on title text.
209
240
 
210
- After the issue is created, write the fingerprint → issue-url mapping to the seen-cache at `{seenCachePath}` so this user never re-reports the same fingerprint.
241
+ After the issue is created, write the fingerprint → issue-url mapping to the seen-cache at `{seenCachePath}` so this user does not re-report it while that issue stays open.
211
242
 
212
243
  **Writing rules — non-negotiable:**
213
244
 
@@ -300,7 +331,13 @@ After each successful `gh issue create`, append to `{seenCachePath}`:
300
331
  }
301
332
  ```
302
333
 
303
- Ensure the parent directory exists. This file is global across the user's machine — not per-project — so the same defect is never re-reported across different repos the user works in.
334
+ Ensure the parent directory exists. This file is global across the user's machine — not per-project — so the same defect is not re-reported across different repos the user works in while its issue stays open.
335
+
336
+ **Merge, never replace.** Read the existing file, set this one key, write the whole object back. A write that emits only the new key silently discards every other fingerprint the user has ever recorded.
337
+
338
+ **Read before you write, and never downgrade a `resolved` record.** Before setting the key, check whether one already exists with `"action": "resolved"`. If it does, and you are here because that resolved defect just recurred, you are recording the regression's new issue: overwrite it with `created` and the new URL, which is correct because a live issue now tracks the recurrence. In every other case leave a `resolved` record alone. The record must never move from `resolved` back to a suppressing action without a new open issue to justify it, or the next run silently re-arms the suppression the fix removed.
339
+
340
+ **The `action` vocabulary is `created`, `reacted`, `commented`, `resolved`.** The first three are written by this workflow. `resolved` is written by hand when the defect is fixed, and it is the only one that does not suppress. See the health-check section of `CONTRIBUTING.md` for when to record it.
304
341
 
305
342
  ### 5c. Local-Queue Path (gh unavailable OR friction/gap default)
306
343
 
@@ -84,6 +84,12 @@
84
84
  "enum": ["full-library", "specific-modules", "public-api", "component-library", "reference-app", "docs-only"]
85
85
  },
86
86
  "include": { "type": "array", "items": { "type": "string" } },
87
+ "tier_a_include": {
88
+ "type": "array",
89
+ "description": "Optional narrower tier-A include list. When present it becomes the coverage-denominator basis instead of the coarse scope.include, and it exempts the brief from the denominator deflation guard in skf-test-skill references/source-access-protocol.md — so an empty or blank-item list must not be accepted: it would silence that guard while narrowing nothing.",
90
+ "minItems": 1,
91
+ "items": { "type": "string", "minLength": 1 }
92
+ },
87
93
  "exclude": { "type": "array", "items": { "type": "string" } },
88
94
  "notes": { "type": "string" },
89
95
  "rationale": {
@@ -378,7 +378,27 @@ def _parse_argv(argv: list[str]) -> dict:
378
378
  return payload
379
379
 
380
380
 
381
+ def _force_utf8(*streams) -> None:
382
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
383
+
384
+ A default Windows console decodes stdio as cp1252, which cannot carry
385
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
386
+ each stream's existing error handler — reconfigure(encoding=...) alone
387
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
388
+ Linux. For stdin this must run before the first read. Skips in-process
389
+ test doubles without reconfigure().
390
+ """
391
+ for stream in streams:
392
+ if hasattr(stream, "reconfigure"):
393
+ errors = getattr(stream, "errors", None)
394
+ if errors is None:
395
+ stream.reconfigure(encoding="utf-8")
396
+ else:
397
+ stream.reconfigure(encoding="utf-8", errors=errors)
398
+
399
+
381
400
  def main(argv: list[str]) -> int:
401
+ _force_utf8(sys.stdin, sys.stdout)
382
402
  payload = _parse_argv(argv)
383
403
  result = detect(payload)
384
404
  json.dump(result, sys.stdout, ensure_ascii=False)
@@ -142,6 +142,29 @@ def _ok(payload: dict) -> None:
142
142
  print(json.dumps(payload))
143
143
 
144
144
 
145
+ def _resolve_outside_cwd(command: str) -> str | None:
146
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
147
+
148
+ shutil.which on Windows searches the current directory ahead of PATH,
149
+ and CWD here is the repo under analysis — a bare-name lookup resolving
150
+ into CWD would execute a repo-planted shim (e.g. ast-grep.cmd). Such a
151
+ resolution is treated as not-found. Explicit paths supplied by callers
152
+ (containing a separator) are honored as-is. Keep identical to the
153
+ sibling guard in skf-qmd-classify-collections.py.
154
+ """
155
+ resolved = shutil.which(command)
156
+ if resolved is None:
157
+ return None
158
+ if os.sep in command or (os.altsep and os.altsep in command):
159
+ return resolved
160
+ resolved_dir = os.path.dirname(resolved)
161
+ if resolved_dir:
162
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
163
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
164
+ return None
165
+ return resolved
166
+
167
+
145
168
  def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
146
169
  """Run a subprocess. Return (returncode, stdout, stderr). Never raises.
147
170
 
@@ -149,6 +172,13 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
149
172
  CalledProcessError) as a failed probe — returns rc=127 and an empty
150
173
  stdout/stderr. Tool detection should never crash the workflow.
151
174
 
175
+ cmd[0] is resolved through shutil.which() before spawning (with the
176
+ CWD-shim guard in _resolve_outside_cwd): Windows CreateProcess only
177
+ ever appends .exe to a bare name, so npm-installed shims
178
+ (ast-grep.CMD, qmd.CMD) would raise FileNotFoundError and read as
179
+ "tool absent". An unresolvable command returns rc=127 without
180
+ spawning anything.
181
+
152
182
  The child is wrapped in the OS `timeout(1)` utility (when available) so a
153
183
  daemon-backed probe blocked in an uninterruptible syscall (e.g.
154
184
  `qmd status` waiting on its daemon socket) is reaped at the OS level —
@@ -158,6 +188,10 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
158
188
  `start_new_session=True` isolates the child's process group; the
159
189
  Python-level timeout is a secondary net set slightly above the OS one.
160
190
  """
191
+ resolved = _resolve_outside_cwd(cmd[0])
192
+ if resolved is None:
193
+ return 127, "", ""
194
+ cmd = [resolved, *cmd[1:]]
161
195
  if _TIMEOUT_BIN:
162
196
  run_cmd = [_TIMEOUT_BIN, "--kill-after=2", str(timeout), *cmd]
163
197
  py_timeout = timeout + 5
@@ -496,7 +496,27 @@ def cmd_emit_blocked() -> None:
496
496
  print(emit_envelope_line(envelope))
497
497
 
498
498
 
499
+ def _force_utf8(*streams) -> None:
500
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
501
+
502
+ A default Windows console decodes stdio as cp1252, which cannot carry
503
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
504
+ each stream's existing error handler — reconfigure(encoding=...) alone
505
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
506
+ Linux. For stdin this must run before the first read. Skips in-process
507
+ test doubles without reconfigure().
508
+ """
509
+ for stream in streams:
510
+ if hasattr(stream, "reconfigure"):
511
+ errors = getattr(stream, "errors", None)
512
+ if errors is None:
513
+ stream.reconfigure(encoding="utf-8")
514
+ else:
515
+ stream.reconfigure(encoding="utf-8", errors=errors)
516
+
517
+
499
518
  def main() -> None:
519
+ _force_utf8(sys.stdin, sys.stdout)
500
520
  parser = argparse.ArgumentParser(
501
521
  description=__doc__,
502
522
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -80,7 +80,27 @@ def corpora_for(language: str) -> list[dict]:
80
80
  return out
81
81
 
82
82
 
83
+ def _force_utf8(*streams) -> None:
84
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
85
+
86
+ A default Windows console decodes stdio as cp1252, which cannot carry
87
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
88
+ each stream's existing error handler — reconfigure(encoding=...) alone
89
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
90
+ Linux. For stdin this must run before the first read. Skips in-process
91
+ test doubles without reconfigure().
92
+ """
93
+ for stream in streams:
94
+ if hasattr(stream, "reconfigure"):
95
+ errors = getattr(stream, "errors", None)
96
+ if errors is None:
97
+ stream.reconfigure(encoding="utf-8")
98
+ else:
99
+ stream.reconfigure(encoding="utf-8", errors=errors)
100
+
101
+
83
102
  def main(argv: list[str]) -> int:
103
+ _force_utf8(sys.stdout, sys.stderr)
84
104
  parser = argparse.ArgumentParser(
85
105
  description="Look up canonical companion prose corpora for a language.",
86
106
  )
@@ -213,7 +213,27 @@ def merge_doc_urls(
213
213
  return kept, suppressed
214
214
 
215
215
 
216
+ def _force_utf8(*streams) -> None:
217
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
218
+
219
+ A default Windows console decodes stdio as cp1252, which cannot carry
220
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
221
+ each stream's existing error handler — reconfigure(encoding=...) alone
222
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
223
+ Linux. For stdin this must run before the first read. Skips in-process
224
+ test doubles without reconfigure().
225
+ """
226
+ for stream in streams:
227
+ if hasattr(stream, "reconfigure"):
228
+ errors = getattr(stream, "errors", None)
229
+ if errors is None:
230
+ stream.reconfigure(encoding="utf-8")
231
+ else:
232
+ stream.reconfigure(encoding="utf-8", errors=errors)
233
+
234
+
216
235
  def main() -> int:
236
+ _force_utf8(sys.stdin, sys.stdout)
217
237
  raw = sys.stdin.read()
218
238
  if not raw or not raw.strip():
219
239
  sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
@@ -79,7 +79,9 @@ from __future__ import annotations
79
79
 
80
80
  import argparse
81
81
  import json
82
+ import os
82
83
  import re
84
+ import shutil
83
85
  import sys
84
86
  from pathlib import Path
85
87
 
@@ -92,6 +94,10 @@ FOREIGN_SAMPLE_CAP = 5
92
94
  # Header line emitted by newer qmd builds, e.g. "Collections (56):".
93
95
  _QMD_HEADER_RE = re.compile(r"^Collections \(\d+\):\s*$")
94
96
 
97
+ # Empty-state message from `qmd collection list`, e.g.
98
+ # "No collections found. Run 'qmd collection add .' to create one."
99
+ _QMD_EMPTY_STATE_PREFIX = "No collections found"
100
+
95
101
 
96
102
  def _die(code: int, message: str) -> None:
97
103
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
@@ -199,7 +205,12 @@ def parse_collection_list_output(raw: str) -> list[str]:
199
205
  the trailing ` (qmd://name/)` URI and is a no-op for the bare-name form.
200
206
  Without this, suffixed entries fail the `is_forge_owned` suffix check and
201
207
  every forge collection is mis-classified as foreign.
208
+
209
+ The empty-state message ("No collections found. …") is recognized before
210
+ per-line tokenizing — it would otherwise parse as a collection named "No".
202
211
  """
212
+ if raw.lstrip().startswith(_QMD_EMPTY_STATE_PREFIX):
213
+ return []
203
214
  names: list[str] = []
204
215
  for line in raw.splitlines():
205
216
  if not line.strip():
@@ -212,6 +223,29 @@ def parse_collection_list_output(raw: str) -> list[str]:
212
223
  return names
213
224
 
214
225
 
226
+ def _resolve_outside_cwd(command: str) -> str | None:
227
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
228
+
229
+ shutil.which on Windows searches the current directory ahead of PATH,
230
+ and CWD here is the repo under analysis — a bare-name lookup resolving
231
+ into CWD would execute a repo-planted shim (e.g. qmd.cmd). Such a
232
+ resolution is treated as not-found. Explicit paths supplied by callers
233
+ (containing a separator) are honored as-is. Keep identical to the
234
+ sibling guard in skf-detect-tools.py.
235
+ """
236
+ resolved = shutil.which(command)
237
+ if resolved is None:
238
+ return None
239
+ if os.sep in command or (os.altsep and os.altsep in command):
240
+ return resolved
241
+ resolved_dir = os.path.dirname(resolved)
242
+ if resolved_dir:
243
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
244
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
245
+ return None
246
+ return resolved
247
+
248
+
215
249
  def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
216
250
  """Invoke `qmd collection list` and return (names, error).
217
251
 
@@ -221,11 +255,19 @@ def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
221
255
  counts as a "live collection name".
222
256
  """
223
257
  import subprocess
258
+ # Resolve before spawning: on Windows qmd ships from npm as a .CMD
259
+ # shim, which a bare-name subprocess.run cannot launch (WinError 2).
260
+ qmd = _resolve_outside_cwd("qmd")
261
+ if qmd is None:
262
+ return [], "qmd not found on PATH"
224
263
  try:
264
+ # qmd emits UTF-8; a locale-default decode (cp1252 on Windows)
265
+ # mojibakes names or raises UnicodeDecodeError on unmapped bytes.
225
266
  result = subprocess.run(
226
- ["qmd", "collection", "list"],
267
+ [qmd, "collection", "list"],
227
268
  capture_output=True,
228
- text=True,
269
+ encoding="utf-8",
270
+ errors="replace",
229
271
  timeout=30,
230
272
  check=False,
231
273
  )
@@ -356,7 +356,27 @@ def _parse_argv(argv: list[str]) -> dict:
356
356
  return payload
357
357
 
358
358
 
359
+ def _force_utf8(*streams) -> None:
360
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
361
+
362
+ A default Windows console decodes stdio as cp1252, which cannot carry
363
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
364
+ each stream's existing error handler — reconfigure(encoding=...) alone
365
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
366
+ Linux. For stdin this must run before the first read. Skips in-process
367
+ test doubles without reconfigure().
368
+ """
369
+ for stream in streams:
370
+ if hasattr(stream, "reconfigure"):
371
+ errors = getattr(stream, "errors", None)
372
+ if errors is None:
373
+ stream.reconfigure(encoding="utf-8")
374
+ else:
375
+ stream.reconfigure(encoding="utf-8", errors=errors)
376
+
377
+
359
378
  def main(argv: list[str]) -> int:
379
+ _force_utf8(sys.stdin, sys.stdout)
360
380
  payload = _parse_argv(argv)
361
381
  result = recommend(payload)
362
382
  json.dump(result, sys.stdout, ensure_ascii=False)
@@ -64,7 +64,7 @@ Judgment payload (stdin JSON, compute mode)::
64
64
  {
65
65
  "exports_public_api": 40, # int — public-entry-point export count (judgment)
66
66
  "exports_internal": 19, # int — non-underscore internal export count (judgment)
67
- "effective_denominator": 12, # int|null — optional, stratified-scope monorepo only
67
+ "effective_denominator": 12, # int|null — optional, curated-subset scopes only
68
68
  "pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
69
69
  "exports_documented": 0, # int — stack only (own-barrel count, usually 0)
70
70
  "scripts": [ ... ], # list — scripts[] array (or "scripts_count": int)
@@ -1075,7 +1075,27 @@ def path_to_manifest_name(ecosystem: str) -> str:
1075
1075
  # CLI wiring
1076
1076
  # ---------------------------------------------------------------------------
1077
1077
 
1078
+ def _force_utf8(*streams) -> None:
1079
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
1080
+
1081
+ A default Windows console decodes stdio as cp1252, which cannot carry
1082
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
1083
+ each stream's existing error handler — reconfigure(encoding=...) alone
1084
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
1085
+ Linux. For stdin this must run before the first read. Skips in-process
1086
+ test doubles without reconfigure().
1087
+ """
1088
+ for stream in streams:
1089
+ if hasattr(stream, "reconfigure"):
1090
+ errors = getattr(stream, "errors", None)
1091
+ if errors is None:
1092
+ stream.reconfigure(encoding="utf-8")
1093
+ else:
1094
+ stream.reconfigure(encoding="utf-8", errors=errors)
1095
+
1096
+
1078
1097
  def main(argv: list[str]) -> int:
1098
+ _force_utf8(sys.stdout, sys.stderr)
1079
1099
  parser = argparse.ArgumentParser(
1080
1100
  description="Classify a repo into a known skill shape from its manifest files.",
1081
1101
  )
@@ -13,7 +13,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
13
13
  | source_repo | string | GitHub URL or local path | Repository or project root (optional when `source_type: "docs-only"`) |
14
14
  | language | string | Recognized language | Primary programming language |
15
15
  | scope | object | See Scope Object below | Boundary definition |
16
- | description | string | 1-3 sentences | What the skill covers |
16
+ | description | string | 1-3 sentences | What the skill covers. Must contain a literal `Use when` clause — validators test for that exact phrase. |
17
17
  | forge_tier | string | `Quick` / `Forge` / `Forge+` / `Deep` | Inherited from forge-tier.yaml (Title Case) |
18
18
  | created | string | ISO date `YYYY-MM-DD` | Generation date |
19
19
  | created_by | string | user_name from config | Who generated the brief |
@@ -31,6 +31,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
31
31
  | `constituent_refs` | object | map of path→ref strings | Optional. Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source `project_paths` array. When absent, a single `target_ref` (or auto-detected ref) applies to all paths. Mutually exclusive with `target_ref`. |
32
32
  | `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
33
33
  | `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
34
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen. See `skf-test-skill/references/source-access-protocol.md`. |
34
35
 
35
36
  When `source_type: "docs-only"`:
36
37
  - `source_repo` becomes optional (set to doc site URL for reference)
@@ -59,7 +59,7 @@ For each qualifying unit, prepare a recommendation card:
59
59
  - name: {suggested kebab-case name}
60
60
  - scope.type: {full-library / specific-modules / public-api / component-library / reference-app / docs-only}
61
61
  - scope.include: {suggested glob patterns}
62
- - description: {suggested 1-3 sentence description}
62
+ - description: {suggested 1-3 sentence description, containing a literal `Use when` clause}
63
63
  ```
64
64
 
65
65
  ### 3. Present Recommendations
@@ -143,7 +143,7 @@ Skip this section. Temporal context requires Deep tier.
143
143
 
144
144
  For each export in the skill baseline that was NOT found at its recorded file path during re-extraction (potential "deleted" export):
145
145
 
146
- 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `ccc search "{export_name}" --path {source_root} --top 5` (CLI) — to find candidate current locations
146
+ 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `cd {source_root} && ccc search --limit 5 "{export_name}"` (CLI) — to find candidate current locations. `ccc search` reads the index in the current working directory and has no project-selector flag (`--path` is a file-path glob filter *within* the index, and the result cap is `--limit`, not `--top`) — see `knowledge/ccc-bridge.md`.
147
147
  2. If CCC returns files containing the export name:
148
148
  - Run ast-grep verification on each candidate file
149
149
  - If verified at a new location: reclassify from "deleted" to "moved" with the new file:line reference
@@ -6,9 +6,9 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
6
6
 
7
7
  > Render Markdown to HTML using the marked library. Use when the user pastes raw Markdown and wants formatted output, or asks how to convert MD files in a build pipeline.
8
8
 
9
- > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Triggers on tasks involving Stripe-managed payments, subscription billing, or webhook event handling.
9
+ > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Use when the task involves Stripe-managed payments, subscription billing, or webhook event handling.
10
10
 
11
- > Charts and visualizations powered by D3.js. Reach for this when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
11
+ > Charts and visualizations powered by D3.js. Use when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
12
12
 
13
13
  > Lint Python code with Ruff. Use when the user wants to add or configure Ruff in a Python project, debug rule selectors, or understand why a specific check fired.
14
14
 
@@ -16,4 +16,6 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
16
16
 
17
17
  ## Notes on Voice
18
18
 
19
- Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) and matches the trigger phrasing ("Use when...", "Triggers on...", "Reach for this when...") to the voice rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
19
+ Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
20
+
21
+ The **lead** is where the voice varies; the **trigger clause** is fixed. Every description must contain a literal `Use when` clause somewhere, because validators test for that exact phrase — `skill-check`'s `description.use_when_phrase` rule scores a description below 100 without it. Phrasings like "Triggers on…" or "Reach for this when…" read well but do not satisfy it on their own, so keep them for additional text rather than as the only trigger.