bmad-module-skill-forge 2.0.2 → 2.2.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -1
  3. package/docs/404.md +4 -4
  4. package/docs/_data/pinned.yaml +1 -1
  5. package/docs/_internal/RELEASING.md +41 -4
  6. package/docs/agents.md +2 -2
  7. package/docs/architecture.md +1 -1
  8. package/docs/bmad-synergy.md +13 -13
  9. package/docs/campaign.md +3 -3
  10. package/docs/concepts.md +3 -3
  11. package/docs/examples.md +9 -9
  12. package/docs/forge-auto.md +6 -6
  13. package/docs/getting-started.md +12 -12
  14. package/docs/how-it-works.md +7 -7
  15. package/docs/index.md +7 -1
  16. package/docs/skill-model.md +2 -2
  17. package/docs/troubleshooting.md +7 -7
  18. package/docs/why-skf.md +8 -8
  19. package/docs/workflows.md +5 -5
  20. package/package.json +7 -3
  21. package/src/shared/references/description-guard-protocol.md +2 -0
  22. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  23. package/src/shared/scripts/skf-description-guard.py +22 -2
  24. package/src/shared/scripts/skf-detect-docs.py +198 -2
  25. package/src/shared/scripts/skf-detect-language.py +20 -0
  26. package/src/shared/scripts/skf-detect-tools.py +40 -0
  27. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  28. package/src/shared/scripts/skf-language-corpora.py +20 -0
  29. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  30. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  31. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  32. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  33. package/src/shared/scripts/skf-shape-detect.py +20 -0
  34. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  35. package/src/skf-analyze-source/references/recommend.md +1 -1
  36. package/src/skf-audit-skill/references/re-index.md +1 -1
  37. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  38. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  39. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  40. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  41. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  42. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  43. package/src/skf-create-skill/assets/skill-sections.md +3 -2
  44. package/src/skf-create-skill/references/compile.md +12 -3
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  47. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  48. package/src/skf-create-skill/references/step-doc-sources.md +31 -3
  49. package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
  50. package/src/skf-create-skill/references/validate.md +2 -2
  51. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  52. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  53. package/src/skf-setup/references/ccc-index.md +5 -4
  54. package/src/skf-setup/references/detect-and-tier.md +1 -0
  55. package/src/skf-setup/references/report.md +1 -1
  56. package/src/skf-test-skill/references/coverage-check.md +11 -5
  57. package/src/skf-test-skill/references/score.md +7 -1
  58. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  59. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  60. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  61. package/src/skf-update-skill/references/write.md +2 -2
  62. package/tools/cli/lib/installer.js +31 -1
  63. package/tools/validate-docs-links.js +665 -0
@@ -3,7 +3,7 @@ title: Troubleshooting
3
3
  description: Common errors in Skill Forge — forge setup, ecosystem checks, tier confidence — and how to resolve them.
4
4
  ---
5
5
 
6
- If something isn't working, start here. For general setup help see [Getting Started → Need help?](../getting-started/#need-help).
6
+ If something isn't working, start here. For general setup help see [Getting Started → Need help?](/docs/getting-started.md#need-help).
7
7
 
8
8
  ---
9
9
 
@@ -13,13 +13,13 @@ If something isn't working, start here. For general setup help see [Getting Star
13
13
 
14
14
  Surfaced by `/skf-setup` On Activation when `uv --version` is missing on `$PATH`. SKF helpers depend on `uv` to auto-resolve their Python dependencies via PEP 723 inline metadata; bare `python3` ignores that metadata and would fail later with `ModuleNotFoundError: No module named 'yaml'`. The probe halts the workflow up-front with one cohesive diagnostic instead of letting five steps each fail individually.
15
15
 
16
- **Fix:** install `uv` from <https://docs.astral.sh/uv/getting-started/installation/> and re-run `/skf-setup`. `uv` is documented as a runtime prerequisite in [Getting Started → Prerequisites](../getting-started/#prerequisites-full-reference).
16
+ **Fix:** install `uv` from <https://docs.astral.sh/uv/getting-started/installation/> and re-run `/skf-setup`. `uv` is documented as a runtime prerequisite in [Getting Started → Prerequisites](/docs/getting-started.md#prerequisites-full-reference).
17
17
 
18
18
  ### "Setup cannot proceed: `_bmad/skf/config.yaml` was not found"
19
19
 
20
20
  Surfaced by `/skf-setup` On Activation when the SKF install config is missing — typically because you invoked `/skf-setup` from a directory that is not an SKF-initialised project. The check runs before any file mutation so nothing is written.
21
21
 
22
- **Fix:** from the project root, run `npx bmad-module-skill-forge install` (or `npx bmad-method install` and add SKF as a custom module — see [Getting Started → Install](../getting-started/#install)), then re-run `/skf-setup`. If you ARE in the right project but the file was deleted, restore it from version control or re-run the SKF installer. A separate "config.yaml is not valid YAML" diagnostic surfaces the parser error inline if the file exists but is malformed — open the file at the named path and repair the YAML.
22
+ **Fix:** from the project root, run `npx bmad-module-skill-forge install` (or `npx bmad-method install` and add SKF as a custom module — see [Getting Started → Install](/docs/getting-started.md#install)), then re-run `/skf-setup`. If you ARE in the right project but the file was deleted, restore it from version control or re-run the SKF installer. A separate "config.yaml is not valid YAML" diagnostic surfaces the parser error inline if the file exists but is malformed — open the file at the named path and repair the YAML.
23
23
 
24
24
  ### Forge reports ast-grep is unavailable
25
25
 
@@ -35,7 +35,7 @@ An official skill already exists for this package. Consider installing it with `
35
35
 
36
36
  ### Quick-tier skills have lower confidence scores
37
37
 
38
- Quick tier reads source without AST analysis, so signatures are read directly from files rather than structurally verified. Install ast-grep to upgrade to the Forge tier for AST-verified signatures (T1 confidence) — see [Capability Tiers](../concepts/#capability-tiers-quickforgeforgedeep).
38
+ Quick tier reads source without AST analysis, so signatures are read directly from files rather than structurally verified. Install ast-grep to upgrade to the Forge tier for AST-verified signatures (T1 confidence) — see [Capability Tiers](/docs/concepts.md#capability-tiers-quickforgeforgedeep).
39
39
 
40
40
  ### Want semantic discovery for large codebases?
41
41
 
@@ -43,11 +43,11 @@ Install [cocoindex-code](https://github.com/cocoindex-io/cocoindex-code) to unlo
43
43
 
44
44
  ### `@Ferris deepwiki` shows a deprecation notice
45
45
 
46
- The auto pipeline was briefly named `deepwiki`; it's now [`forge-auto`](../forge-auto/) — renamed to avoid confusion with the DeepWiki MCP, since the pipeline compiles a verified skill from source and does **not** call that MCP. `deepwiki` still works (it resolves to `forge-auto`) but prints a one-time notice. Switch your commands to `@Ferris forge-auto <repo-or-doc-url>`.
46
+ The auto pipeline was briefly named `deepwiki`; it's now [`forge-auto`](/docs/forge-auto.md) — renamed to avoid confusion with the DeepWiki MCP, since the pipeline compiles a verified skill from source and does **not** call that MCP. `deepwiki` still works (it resolves to `forge-auto`) but prints a one-time notice. Switch your commands to `@Ferris forge-auto <repo-or-doc-url>`.
47
47
 
48
48
  ### `@Ferris onboard` returns an error
49
49
 
50
- The `onboard` alias was removed. Its replacement is [`forge-auto`](../forge-auto/), which does everything `onboard` did plus auto-scope, auto-brief, and a stricter 90% quality gate. Run `@Ferris forge-auto <repo-or-doc-url>` instead.
50
+ The `onboard` alias was removed. Its replacement is [`forge-auto`](/docs/forge-auto.md), which does everything `onboard` did plus auto-scope, auto-brief, and a stricter 90% quality gate. Run `@Ferris forge-auto <repo-or-doc-url>` instead.
51
51
 
52
52
  ### forge-auto halted at the Test stage
53
53
 
@@ -64,4 +64,4 @@ Campaign is designed for exactly this. State lives in `_campaign-state.yaml` on
64
64
  1. Run `@Ferris SF` to check your tool availability and current tier
65
65
  2. Check `forge-tier.yaml` in your forger sidecar for your configuration
66
66
  3. If `/bmad-help` is installed (via full BMAD Method), run it and describe your state — e.g. `/bmad-help my batch creation failed halfway, how do I resume?`
67
- 4. [File an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) — SKF's [health check system](../workflows/#terminal-step-health-check) is the primary feedback channel, and manual issues feed the same pipeline
67
+ 4. [File an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) — SKF's [health check system](/docs/workflows.md#terminal-step-health-check) is the primary feedback channel, and manual issues feed the same pipeline
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
 
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.2",
4
+ "version": "2.2.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",
@@ -63,6 +63,8 @@ Output JSON:
63
63
 
64
64
  If `restored == true`, also update the in-context copy of `description` to match `guarded_description` so subsequent stages do not work from a stale tool-mutated version. Record `description_guard_restored: true` (with the tool name) in workflow context for the evidence report.
65
65
 
66
+ **Empty snapshot refusal.** The helper exits 1 and leaves the file untouched when `--captured-description` is empty or whitespace-only. There is nothing to restore from: either `guarded_description` was lost from context between capture and verify, or the field was already empty before the tool ran. Never retry with the empty value — writing it back would blank the field the guard exists to protect. If the compiled description is still available in context (the in-context SKILL.md copy), re-run `verify-restore` with that value. Otherwise record `description_guard_restored: false` and `description_guard_refused: empty-capture` (with the tool name) in workflow context; the calling stage's evidence-report populator renders that as a fired guard (`Restored: false`, the triggering tool, `Original description preserved: false`, `Notes: guard refused — empty captured snapshot (empty-capture)`) rather than as the all-`—` clean-run state, and the frontmatter validator surfaces the empty field through the normal artifact path.
67
+
66
68
  ## Why Token-Stream Comparison
67
69
 
68
70
  Token-stream comparison is the documented sweet spot between two failure modes:
@@ -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": {
@@ -32,6 +32,9 @@ Subcommands:
32
32
  "current_description": "..."}
33
33
  If not diverged (including whitespace-only differences), emit
34
34
  {"diverged": false, "restored": false, "diff_kind": "none"|"whitespace-only"}
35
+ An empty or whitespace-only --captured-description is refused with
36
+ exit 1 before the file is read: there is nothing to restore from, and
37
+ writing it back would blank the very field this guard protects.
35
38
 
36
39
  Token-stream comparison is the documented sweet spot — catches replaced
37
40
  words, truncation, and reintroduced angle-brackets while ignoring cosmetic
@@ -47,7 +50,8 @@ is auto-resolved:
47
50
 
48
51
  Exit codes:
49
52
  0 — operation succeeded (including no-divergence verify)
50
- 1 — user error (bad args, file missing, frontmatter unparseable)
53
+ 1 — user error (bad args, empty captured description, file missing,
54
+ frontmatter unparseable)
51
55
  2 — operation failure (restore write failed)
52
56
  """
53
57
 
@@ -173,6 +177,11 @@ def restore_description(skill_md: Path, captured: str) -> None:
173
177
  chooses for each scalar — downstream readers parse YAML, so any valid
174
178
  YAML emission is acceptable.
175
179
  """
180
+ if not captured.strip():
181
+ # Defence in depth for library callers: the CLI refuses this earlier,
182
+ # but no code path may ever write an empty description.
183
+ raise ValueError("cannot restore: captured description is empty or whitespace-only")
184
+
176
185
  text = skill_md.read_text(encoding="utf-8")
177
186
  leading, fm_yaml, body = _split_frontmatter(text)
178
187
  if not fm_yaml:
@@ -238,6 +247,18 @@ def _cmd_capture(args: argparse.Namespace) -> int:
238
247
 
239
248
 
240
249
  def _cmd_verify_restore(args: argparse.Namespace) -> int:
250
+ captured = args.captured_description
251
+ if not captured.strip():
252
+ # Restoring an empty snapshot would blank the field the guard exists
253
+ # to protect. The snapshot was lost from context, or the description
254
+ # was already empty before the tool ran; either way there is nothing
255
+ # to restore, so refuse before touching the file.
256
+ _fail(
257
+ "--captured-description is empty or whitespace-only; refusing to "
258
+ "overwrite the on-disk description with an empty value (the capture "
259
+ "was lost, or the field was already empty before the tool ran)"
260
+ )
261
+
241
262
  skill_md = Path(args.skill_md)
242
263
  if not skill_md.is_file():
243
264
  _fail(f"file not found: {skill_md}")
@@ -246,7 +267,6 @@ def _cmd_verify_restore(args: argparse.Namespace) -> int:
246
267
  except ValueError as exc:
247
268
  _fail(str(exc))
248
269
 
249
- captured = args.captured_description
250
270
  diff_kind = classify_divergence(captured, current)
251
271
  diverged = is_diverged(diff_kind)
252
272
 
@@ -74,6 +74,42 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
74
74
  `algo:` prefix stripped) before comparison so a bare-hex writer form still
75
75
  matches. Exit 0 on any well-formed input (never blocks the informational
76
76
  audit); exit 2 on malformed args or JSON.
77
+
78
+ ---------------------------------------------------------------------------
79
+ Subcommand: hash-urls
80
+ ---------------------------------------------------------------------------
81
+
82
+ Compile-time `doc_sources[]` for docs-only skills (create-skill step 5a).
83
+ The detect path above needs a GitHub repository; a docs-only brief has none
84
+ — its `source_repo` is the documentation site and its corpus is the brief's
85
+ `doc_urls`. This subcommand fetches each URL and hashes the raw response
86
+ bytes with the SAME primitive `compare-hashes` uses, so a hash recorded here
87
+ compares byte-for-byte at audit time. (Hashing the markdown a web-fetch tool
88
+ rendered would not: the audit re-fetches the raw bytes.)
89
+
90
+ uv run src/shared/scripts/skf-detect-docs.py hash-urls <source>
91
+
92
+ Input `<source>`:
93
+ - a path to a JSON file that is EITHER an array whose items are URL
94
+ strings or `{url, ...}` objects (the brief's `doc_urls[]` entry shape)
95
+ OR an object with a `doc_urls` array (the brief block itself), OR
96
+ - `-` to read that JSON from stdin.
97
+
98
+ Output (JSON object on stdout):
99
+ {
100
+ "doc_sources": [{"url", "detected_via": "brief_doc_urls",
101
+ "content_hash": "sha256:..."|null, "recorded_at"}],
102
+ "fetch_failed": [{"url", "reason"}],
103
+ "stats": {"total", "hashed", "fetch_failed", "invalid"}
104
+ }
105
+
106
+ URLs keep their input order and are deduplicated on first occurrence; an
107
+ entry without a usable url string is dropped and counted under
108
+ `stats.invalid`. A URL that cannot be fetched still gets an entry with
109
+ `content_hash: null` — the audit side files it under `skipped_null_hash`
110
+ instead of reporting drift. `doc_sources` is already in the metadata.json
111
+ schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
112
+ well-formed input; exit 2 on malformed args or JSON.
77
113
  """
78
114
 
79
115
  from __future__ import annotations
@@ -88,6 +124,7 @@ import subprocess
88
124
  import sys
89
125
  import urllib.error
90
126
  import urllib.request
127
+ from datetime import datetime, timezone
91
128
  from pathlib import Path
92
129
  from typing import Any, Dict, List, Optional, Tuple
93
130
 
@@ -252,8 +289,10 @@ def _fetch_and_hash(url: str) -> Optional[str]:
252
289
  return "sha256:" + hashlib.sha256(content).hexdigest()
253
290
  except (OSError, IOError):
254
291
  return None
255
- req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
256
292
  try:
293
+ # Request() itself raises ValueError on a scheme-less or relative URL;
294
+ # it must sit inside the guard so a bad entry degrades to None.
295
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
257
296
  with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
258
297
  content = resp.read()
259
298
  return "sha256:" + hashlib.sha256(content).hexdigest()
@@ -294,7 +333,14 @@ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
294
333
  except OSError as exc:
295
334
  return None, f"local read failed: {exc}"
296
335
  return "sha256:" + hashlib.sha256(content).hexdigest(), None
297
- req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
336
+ try:
337
+ # Request() raises ValueError ("unknown url type") on a scheme-less or
338
+ # relative URL — a legal brief entry, since the brief schema only
339
+ # requires a non-empty string. Keep it inside the guard so one bad
340
+ # entry lands in fetch_failed instead of aborting the whole run.
341
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
342
+ except ValueError as exc:
343
+ return None, f"invalid URL: {exc}"
298
344
  try:
299
345
  with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
300
346
  content = resp.read()
@@ -601,16 +647,166 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
601
647
  return 0
602
648
 
603
649
 
650
+ # ---------------------------------------------------------------------------
651
+ # Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
652
+ # ---------------------------------------------------------------------------
653
+
654
+ # `doc_sources[].detected_via` value for entries that come from the brief's
655
+ # `doc_urls` (and the subpages step 3c fetched from them) rather than from
656
+ # repository detection. Listed in skf-create-skill/assets/skill-sections.md.
657
+ BRIEF_DOC_URLS_DETECTED_VIA = "brief_doc_urls"
658
+
659
+
660
+ def _load_url_list(raw_text: str, source_label: str) -> List[Any]:
661
+ """Parse a hash-urls input blob into a list of URL entries.
662
+
663
+ Accepts a top-level JSON array whose items are URL strings or objects
664
+ with a `url` key (the brief's `doc_urls[]` entry shape), or a top-level
665
+ object with a `doc_urls` array (the brief block itself). An object without
666
+ `doc_urls` yields an empty list (nothing to hash).
667
+
668
+ Raises ValueError on malformed JSON or an unexpected top-level shape.
669
+ """
670
+ try:
671
+ data = json.loads(raw_text)
672
+ except json.JSONDecodeError as exc:
673
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
674
+ if isinstance(data, list):
675
+ return data
676
+ if isinstance(data, dict):
677
+ entries = data.get("doc_urls")
678
+ if entries is None:
679
+ return []
680
+ if not isinstance(entries, list):
681
+ raise ValueError(f"`doc_urls` in {source_label} is not an array")
682
+ return entries
683
+ raise ValueError(
684
+ f"{source_label} must be a JSON array or an object with a "
685
+ f"`doc_urls` array; got {type(data).__name__}"
686
+ )
687
+
688
+
689
+ def hash_urls(entries: List[Any], recorded_at: Optional[str] = None) -> Dict[str, Any]:
690
+ """Fetch and hash each URL into a compile-time `doc_sources[]` entry.
691
+
692
+ Byte-symmetric with `compare_doc_hashes`: both go through
693
+ `_fetch_and_hash_reason`, so a hash recorded here reads as `unchanged` at
694
+ audit time for unchanged bytes. URLs keep their input order (brief order,
695
+ then discovered subpages) and are deduplicated on first occurrence. A URL
696
+ that cannot be fetched still gets an entry with `content_hash: None`, which
697
+ the audit side skips rather than reporting as drift.
698
+ """
699
+ stamp = recorded_at or datetime.now(timezone.utc).isoformat()
700
+ doc_sources: List[Dict[str, Any]] = []
701
+ fetch_failed: List[Dict[str, Any]] = []
702
+ seen: set = set()
703
+ invalid = 0
704
+ for entry in entries:
705
+ url = entry.get("url") if isinstance(entry, dict) else entry
706
+ if not isinstance(url, str) or not url.strip():
707
+ invalid += 1
708
+ continue
709
+ if url in seen:
710
+ continue
711
+ seen.add(url)
712
+ content_hash, reason = _fetch_and_hash_reason(url)
713
+ doc_sources.append({
714
+ "url": url,
715
+ "detected_via": BRIEF_DOC_URLS_DETECTED_VIA,
716
+ "content_hash": content_hash,
717
+ "recorded_at": stamp,
718
+ })
719
+ if content_hash is None:
720
+ fetch_failed.append({"url": url, "reason": reason})
721
+ return {
722
+ "doc_sources": doc_sources,
723
+ "fetch_failed": fetch_failed,
724
+ "stats": {
725
+ "total": len(doc_sources),
726
+ "hashed": len(doc_sources) - len(fetch_failed),
727
+ "fetch_failed": len(fetch_failed),
728
+ "invalid": invalid,
729
+ },
730
+ }
731
+
732
+
733
+ def _cmd_hash_urls(argv: List[str]) -> int:
734
+ parser = argparse.ArgumentParser(
735
+ prog="skf-detect-docs.py hash-urls",
736
+ description=(
737
+ "Fetch each documentation URL, hash the response bytes, and emit "
738
+ "compile-time doc_sources entries (detected_via: brief_doc_urls) "
739
+ "for a docs-only skill."
740
+ ),
741
+ )
742
+ parser.add_argument(
743
+ "source",
744
+ help=(
745
+ "path to a JSON file (array of URL strings or {url, ...} objects, "
746
+ "or an object with a doc_urls array such as the brief's block), "
747
+ "or - to read that JSON from stdin"
748
+ ),
749
+ )
750
+ args = parser.parse_args(argv)
751
+
752
+ if args.source == "-":
753
+ raw = sys.stdin.read()
754
+ label = "<stdin>"
755
+ else:
756
+ path = Path(args.source)
757
+ try:
758
+ raw = path.read_text(encoding="utf-8")
759
+ except OSError as exc:
760
+ json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
761
+ sys.stderr.write("\n")
762
+ return 2
763
+ label = str(path)
764
+
765
+ try:
766
+ entries = _load_url_list(raw, label)
767
+ except ValueError as exc:
768
+ json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
769
+ sys.stderr.write("\n")
770
+ return 2
771
+
772
+ result = hash_urls(entries)
773
+ json.dump(result, sys.stdout, separators=(",", ":"))
774
+ sys.stdout.write("\n")
775
+ return 0
776
+
777
+
604
778
  # ---------------------------------------------------------------------------
605
779
  # CLI entry point
606
780
  # ---------------------------------------------------------------------------
607
781
 
782
+ def _force_utf8(*streams) -> None:
783
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
784
+
785
+ A default Windows console decodes stdio as cp1252, which cannot carry
786
+ non-ASCII JSON (raw UTF-8 input on stdin for `compare-hashes -` and
787
+ `hash-urls -`). Preserves each stream's existing error handler —
788
+ reconfigure(encoding=...) alone would reset it to 'strict', downgrading
789
+ e.g. an already-UTF-8 stderr on Linux. For stdin this must run before the
790
+ first read. Skips in-process test doubles without reconfigure().
791
+ """
792
+ for stream in streams:
793
+ if hasattr(stream, "reconfigure"):
794
+ errors = getattr(stream, "errors", None)
795
+ if errors is None:
796
+ stream.reconfigure(encoding="utf-8")
797
+ else:
798
+ stream.reconfigure(encoding="utf-8", errors=errors)
799
+
800
+
608
801
  def main() -> int:
802
+ _force_utf8(sys.stdin, sys.stdout)
609
803
  # Additive subcommand: route `compare-hashes ...` to the doc-drift path
610
804
  # before the existing detect parser runs. The default (no subcommand)
611
805
  # invocation `--repo-url <url> ...` is unchanged.
612
806
  if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
613
807
  return _cmd_compare_hashes(sys.argv[2:])
808
+ if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
809
+ return _cmd_hash_urls(sys.argv[2:])
614
810
 
615
811
  parser = argparse.ArgumentParser(
616
812
  description="Detect documentation URLs for a GitHub repository.",
@@ -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)
@@ -89,6 +89,7 @@ DETECT_OUTPUT_SCHEMA (v1):
89
89
  "previous_ccc_indexed_path": str|null,
90
90
  "previous_ccc_last_indexed": str|null,
91
91
  "previous_ccc_staleness_threshold_hours": int|null,
92
+ "previous_ccc_file_count": int|null,
92
93
  # Deterministic CCC-index freshness verdict — computed against
93
94
  # --project-root and datetime.now(UTC) captured at detect time, so the
94
95
  # ccc-index.md step branches on a boolean instead of doing timestamp math.
@@ -142,6 +143,29 @@ def _ok(payload: dict) -> None:
142
143
  print(json.dumps(payload))
143
144
 
144
145
 
146
+ def _resolve_outside_cwd(command: str) -> str | None:
147
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
148
+
149
+ shutil.which on Windows searches the current directory ahead of PATH,
150
+ and CWD here is the repo under analysis — a bare-name lookup resolving
151
+ into CWD would execute a repo-planted shim (e.g. ast-grep.cmd). Such a
152
+ resolution is treated as not-found. Explicit paths supplied by callers
153
+ (containing a separator) are honored as-is. Keep identical to the
154
+ sibling guard in skf-qmd-classify-collections.py.
155
+ """
156
+ resolved = shutil.which(command)
157
+ if resolved is None:
158
+ return None
159
+ if os.sep in command or (os.altsep and os.altsep in command):
160
+ return resolved
161
+ resolved_dir = os.path.dirname(resolved)
162
+ if resolved_dir:
163
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
164
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
165
+ return None
166
+ return resolved
167
+
168
+
145
169
  def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
146
170
  """Run a subprocess. Return (returncode, stdout, stderr). Never raises.
147
171
 
@@ -149,6 +173,13 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
149
173
  CalledProcessError) as a failed probe — returns rc=127 and an empty
150
174
  stdout/stderr. Tool detection should never crash the workflow.
151
175
 
176
+ cmd[0] is resolved through shutil.which() before spawning (with the
177
+ CWD-shim guard in _resolve_outside_cwd): Windows CreateProcess only
178
+ ever appends .exe to a bare name, so npm-installed shims
179
+ (ast-grep.CMD, qmd.CMD) would raise FileNotFoundError and read as
180
+ "tool absent". An unresolvable command returns rc=127 without
181
+ spawning anything.
182
+
152
183
  The child is wrapped in the OS `timeout(1)` utility (when available) so a
153
184
  daemon-backed probe blocked in an uninterruptible syscall (e.g.
154
185
  `qmd status` waiting on its daemon socket) is reaped at the OS level —
@@ -158,6 +189,10 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
158
189
  `start_new_session=True` isolates the child's process group; the
159
190
  Python-level timeout is a secondary net set slightly above the OS one.
160
191
  """
192
+ resolved = _resolve_outside_cwd(cmd[0])
193
+ if resolved is None:
194
+ return 127, "", ""
195
+ cmd = [resolved, *cmd[1:]]
161
196
  if _TIMEOUT_BIN:
162
197
  run_cmd = [_TIMEOUT_BIN, "--kill-after=2", str(timeout), *cmd]
163
198
  py_timeout = timeout + 5
@@ -335,6 +370,7 @@ def read_prior_state(prior_state_path) -> dict:
335
370
  "previous_ccc_indexed_path": None,
336
371
  "previous_ccc_last_indexed": None,
337
372
  "previous_ccc_staleness_threshold_hours": None,
373
+ "previous_ccc_file_count": None,
338
374
  }
339
375
  if not prior_state_path:
340
376
  return empty
@@ -359,6 +395,10 @@ def read_prior_state(prior_state_path) -> dict:
359
395
  "previous_ccc_indexed_path": ccc_index.get("indexed_path"),
360
396
  "previous_ccc_last_indexed": ccc_index.get("last_indexed"),
361
397
  "previous_ccc_staleness_threshold_hours": ccc_index.get("staleness_threshold_hours"),
398
+ # Surfaced so ccc-index.md's fresh-index branch can carry the count
399
+ # forward instead of leaving `{ccc_file_count}` unbound (or nulling a
400
+ # value the index still has) when nothing re-indexes this run.
401
+ "previous_ccc_file_count": ccc_index.get("file_count"),
362
402
  }
363
403
 
364
404