bmad-module-skill-forge 2.0.0 → 2.0.2

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 (237) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/workflows.md +1 -1
  5. package/package.json +2 -2
  6. package/src/knowledge/skill-lifecycle.md +1 -1
  7. package/src/module-help.csv +2 -2
  8. package/src/shared/health-check.md +40 -3
  9. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  10. package/src/shared/scripts/skf-count-tokens.py +222 -0
  11. package/src/shared/scripts/skf-detect-docs.py +232 -1
  12. package/src/shared/scripts/skf-detect-language.py +77 -0
  13. package/src/shared/scripts/skf-detect-tools.py +105 -2
  14. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  15. package/src/shared/scripts/skf-find-cycles.py +246 -0
  16. package/src/shared/scripts/skf-hash-content.py +487 -2
  17. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  18. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  19. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  20. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  21. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  22. package/src/shared/scripts/skf-shard-body.py +461 -0
  23. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  24. package/src/shared/scripts/skf-structural-diff.py +307 -67
  25. package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
  26. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  27. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  28. package/src/shared/scripts/skf-validate-output.py +443 -10
  29. package/src/shared/scripts/skf-validate-pins.py +1 -1
  30. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  31. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  32. package/src/skf-analyze-source/SKILL.md +17 -32
  33. package/src/skf-analyze-source/customize.toml +2 -0
  34. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  35. package/src/skf-analyze-source/references/continue.md +8 -13
  36. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  37. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  38. package/src/skf-analyze-source/references/health-check.md +1 -2
  39. package/src/skf-analyze-source/references/identify-units.md +25 -36
  40. package/src/skf-analyze-source/references/init.md +12 -25
  41. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  42. package/src/skf-analyze-source/references/recommend.md +4 -14
  43. package/src/skf-analyze-source/references/scan-project.md +3 -11
  44. package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
  45. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  46. package/src/skf-audit-skill/SKILL.md +9 -6
  47. package/src/skf-audit-skill/customize.toml +2 -0
  48. package/src/skf-audit-skill/references/init.md +58 -32
  49. package/src/skf-audit-skill/references/re-index.md +15 -34
  50. package/src/skf-audit-skill/references/report.md +11 -58
  51. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  52. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  53. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  54. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  55. package/src/skf-brief-skill/SKILL.md +7 -36
  56. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  57. package/src/skf-brief-skill/customize.toml +14 -0
  58. package/src/skf-brief-skill/references/analyze-target.md +6 -14
  59. package/src/skf-brief-skill/references/confirm-brief.md +2 -14
  60. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  61. package/src/skf-brief-skill/references/gather-intent.md +19 -22
  62. package/src/skf-brief-skill/references/headless-args.md +1 -1
  63. package/src/skf-brief-skill/references/health-check.md +1 -22
  64. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  65. package/src/skf-brief-skill/references/scope-definition.md +4 -14
  66. package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
  67. package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
  68. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  69. package/src/skf-brief-skill/references/write-brief.md +17 -10
  70. package/src/skf-campaign/SKILL.md +15 -84
  71. package/src/skf-campaign/customize.toml +2 -0
  72. package/src/skf-campaign/manifest.yaml +4 -0
  73. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  74. package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
  75. package/src/skf-campaign/references/step-01-setup.md +4 -5
  76. package/src/skf-campaign/references/step-02-strategy.md +8 -3
  77. package/src/skf-campaign/references/step-03-pins.md +1 -1
  78. package/src/skf-campaign/references/step-04-provenance.md +1 -1
  79. package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
  80. package/src/skf-campaign/references/step-06-batch.md +7 -6
  81. package/src/skf-campaign/references/step-11-maintenance.md +2 -2
  82. package/src/skf-campaign/references/step-resume.md +9 -8
  83. package/src/skf-campaign/scripts/campaign-deps.py +10 -1
  84. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  85. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  86. package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
  87. package/src/skf-create-skill/SKILL.md +14 -3
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
  89. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  90. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  91. package/src/skf-create-skill/customize.toml +13 -0
  92. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  93. package/src/skf-create-skill/references/compile.md +34 -47
  94. package/src/skf-create-skill/references/component-extraction.md +8 -35
  95. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  96. package/src/skf-create-skill/references/enrich.md +4 -17
  97. package/src/skf-create-skill/references/extract.md +14 -29
  98. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  99. package/src/skf-create-skill/references/extraction-patterns.md +7 -7
  100. package/src/skf-create-skill/references/generate-artifacts.md +9 -23
  101. package/src/skf-create-skill/references/health-check.md +1 -1
  102. package/src/skf-create-skill/references/load-brief.md +16 -18
  103. package/src/skf-create-skill/references/report.md +12 -15
  104. package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
  105. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  106. package/src/skf-create-skill/references/step-doc-rot.md +30 -12
  107. package/src/skf-create-skill/references/step-doc-sources.md +2 -2
  108. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  109. package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
  110. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  111. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  112. package/src/skf-create-skill/references/validate.md +59 -28
  113. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  114. package/src/skf-create-stack-skill/SKILL.md +15 -9
  115. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  116. package/src/skf-create-stack-skill/customize.toml +10 -0
  117. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  118. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  119. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  120. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  121. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  122. package/src/skf-create-stack-skill/references/init.md +9 -81
  123. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  124. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  125. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  126. package/src/skf-create-stack-skill/references/report.md +27 -62
  127. package/src/skf-create-stack-skill/references/validate.md +35 -31
  128. package/src/skf-drop-skill/SKILL.md +20 -23
  129. package/src/skf-drop-skill/customize.toml +10 -0
  130. package/src/skf-drop-skill/references/execute.md +51 -31
  131. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  132. package/src/skf-drop-skill/references/health-check.md +1 -13
  133. package/src/skf-drop-skill/references/report.md +42 -10
  134. package/src/skf-drop-skill/references/select.md +88 -48
  135. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  136. package/src/skf-export-skill/SKILL.md +17 -14
  137. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  138. package/src/skf-export-skill/customize.toml +10 -6
  139. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  140. package/src/skf-export-skill/references/load-skill.md +44 -42
  141. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  142. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  143. package/src/skf-export-skill/references/package.md +27 -33
  144. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  145. package/src/skf-export-skill/references/result-envelope.md +28 -0
  146. package/src/skf-export-skill/references/summary.md +11 -5
  147. package/src/skf-export-skill/references/token-report.md +42 -24
  148. package/src/skf-export-skill/references/update-context.md +32 -33
  149. package/src/skf-forger/SKILL.md +27 -39
  150. package/src/skf-forger/references/pipeline-mode.md +34 -0
  151. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  152. package/src/skf-quick-skill/SKILL.md +10 -60
  153. package/src/skf-quick-skill/customize.toml +9 -0
  154. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  155. package/src/skf-quick-skill/references/compile.md +15 -52
  156. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  157. package/src/skf-quick-skill/references/finalize.md +12 -6
  158. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  159. package/src/skf-quick-skill/references/health-check.md +3 -4
  160. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  161. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  162. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  163. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  164. package/src/skf-refine-architecture/SKILL.md +14 -7
  165. package/src/skf-refine-architecture/customize.toml +9 -0
  166. package/src/skf-refine-architecture/references/compile.md +9 -7
  167. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  168. package/src/skf-refine-architecture/references/health-check.md +1 -1
  169. package/src/skf-refine-architecture/references/improvements.md +9 -25
  170. package/src/skf-refine-architecture/references/init.md +9 -16
  171. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  172. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  173. package/src/skf-refine-architecture/references/report.md +13 -10
  174. package/src/skf-rename-skill/SKILL.md +11 -21
  175. package/src/skf-rename-skill/customize.toml +10 -0
  176. package/src/skf-rename-skill/references/execute.md +107 -78
  177. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  178. package/src/skf-rename-skill/references/report.md +16 -8
  179. package/src/skf-rename-skill/references/select.md +69 -42
  180. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  181. package/src/skf-setup/SKILL.md +20 -12
  182. package/src/skf-setup/customize.toml +10 -0
  183. package/src/skf-setup/references/auto-index.md +9 -10
  184. package/src/skf-setup/references/ccc-index.md +14 -15
  185. package/src/skf-setup/references/detect-and-tier.md +6 -8
  186. package/src/skf-setup/references/report.md +22 -31
  187. package/src/skf-setup/references/tier-rules.md +2 -32
  188. package/src/skf-setup/references/write-config.md +9 -10
  189. package/src/skf-test-skill/SKILL.md +10 -8
  190. package/src/skf-test-skill/customize.toml +2 -0
  191. package/src/skf-test-skill/references/coherence-check.md +19 -31
  192. package/src/skf-test-skill/references/coverage-check.md +109 -75
  193. package/src/skf-test-skill/references/detect-mode.md +10 -41
  194. package/src/skf-test-skill/references/external-validators.md +11 -19
  195. package/src/skf-test-skill/references/init.md +38 -20
  196. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  197. package/src/skf-test-skill/references/report.md +60 -40
  198. package/src/skf-test-skill/references/score.md +32 -64
  199. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  200. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  201. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  202. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  203. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  204. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  205. package/src/skf-test-skill/scripts/compute-score.py +131 -1
  206. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  207. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  208. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  209. package/src/skf-update-skill/SKILL.md +17 -5
  210. package/src/skf-update-skill/customize.toml +14 -0
  211. package/src/skf-update-skill/references/detect-changes.md +19 -37
  212. package/src/skf-update-skill/references/health-check.md +3 -9
  213. package/src/skf-update-skill/references/init.md +39 -32
  214. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  215. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  216. package/src/skf-update-skill/references/merge.md +13 -57
  217. package/src/skf-update-skill/references/re-extract.md +12 -24
  218. package/src/skf-update-skill/references/report.md +14 -7
  219. package/src/skf-update-skill/references/validate.md +32 -30
  220. package/src/skf-update-skill/references/write.md +86 -51
  221. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  222. package/src/skf-verify-stack/SKILL.md +11 -21
  223. package/src/skf-verify-stack/customize.toml +12 -3
  224. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  225. package/src/skf-verify-stack/references/coverage.md +21 -10
  226. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  227. package/src/skf-verify-stack/references/health-check.md +1 -1
  228. package/src/skf-verify-stack/references/init.md +20 -25
  229. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  230. package/src/skf-verify-stack/references/integrations.md +44 -49
  231. package/src/skf-verify-stack/references/report.md +28 -34
  232. package/src/skf-verify-stack/references/requirements.md +3 -3
  233. package/src/skf-verify-stack/references/synthesize.md +36 -39
  234. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  235. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  236. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  237. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """SKF Count Tokens — Deterministic per-artifact token/word metrics for a skill package.
6
+
7
+ Pre-pass that measures the exported artifacts of a single skill package and the
8
+ shared managed section, so the export skill's token report (references/token-report.md)
9
+ and snippet token check (references/generate-snippet.md §4) render exact numbers from
10
+ JSON instead of re-reading every reference file to word-count it in-prompt.
11
+
12
+ Metrics:
13
+ - words: whitespace-split word count (`len(text.split())`) — the report's "Words" column
14
+ - tokens: char-over-four estimate (`len(text) // 4`) — the SKF-wide convention already
15
+ used by skf-validate-output.py, so every SKF token number agrees
16
+
17
+ Measured artifacts (all relative to the positional skill-package dir):
18
+ - context-snippet.md — the compressed passive-context snippet (if present)
19
+ - SKILL.md — the active skill document
20
+ - metadata.json — machine-readable metadata
21
+ - references/* — every file under references/ (recursively), summed as references_total
22
+ - managed section — the complete <!-- SKF:BEGIN ... --> ... <!-- SKF:END --> block,
23
+ extracted from the first --target-file that contains one
24
+ (shared across the batch; measured once)
25
+
26
+ package_total sums context-snippet.md + SKILL.md + metadata.json + references_total and
27
+ DELIBERATELY EXCLUDES the managed-section row — the managed section is a shared all-skills
28
+ cost reported separately, and including it would double-count this skill's own snippet
29
+ (see references/token-report.md §1).
30
+
31
+ CLI:
32
+ python3 skf-count-tokens.py <skill-package-dir>
33
+ python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md
34
+ python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md --target-file AGENTS.md
35
+ python3 skf-count-tokens.py <skill-package-dir> -o report.json
36
+
37
+ Exit codes: 0 = metrics emitted, 2 = error (skill package dir missing / not a directory).
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import re
45
+ import sys
46
+ from pathlib import Path
47
+
48
+ # Mirrors skf-rebuild-managed-sections.py: a BEGIN marker may carry attributes
49
+ # (e.g. `updated:2026-07-13`) before its closing `-->`; the END marker is fixed.
50
+ MANAGED_SECTION_RE = re.compile(
51
+ r"<!-- SKF:BEGIN[^>]*-->.*?<!-- SKF:END -->",
52
+ re.DOTALL,
53
+ )
54
+
55
+ # Core per-skill artifacts, in report order. (references/ is handled separately.)
56
+ CORE_ARTIFACTS = [
57
+ ("context-snippet.md", "context-snippet"),
58
+ ("SKILL.md", "skill-md"),
59
+ ("metadata.json", "metadata"),
60
+ ]
61
+
62
+
63
+ def estimate_tokens(text: str) -> int:
64
+ """Char-over-four token estimate — the SKF-wide convention (see skf-validate-output.py)."""
65
+ return len(text) // 4
66
+
67
+
68
+ def count_words(text: str) -> int:
69
+ """Whitespace-split word count — the token report's 'Words' column."""
70
+ return len(text.split())
71
+
72
+
73
+ def measure(text: str) -> dict:
74
+ """Return the {words, tokens} metrics for a piece of text."""
75
+ return {"words": count_words(text), "tokens": estimate_tokens(text)}
76
+
77
+
78
+ def _read_text(path: Path) -> str:
79
+ # encoding is pinned to utf-8 so non-ASCII content is not mojibaked on
80
+ # Windows (cp1252 default).
81
+ return path.read_text(encoding="utf-8")
82
+
83
+
84
+ def extract_managed_section(text: str) -> str | None:
85
+ """Return the complete BEGIN..END managed-section span (markers included), or None."""
86
+ m = MANAGED_SECTION_RE.search(text)
87
+ return m.group(0) if m else None
88
+
89
+
90
+ def measure_reference_files(references_dir: Path) -> tuple[list[dict], dict]:
91
+ """Measure every file under references/ (recursively, sorted). Returns (per-file rows, total)."""
92
+ rows: list[dict] = []
93
+ total_words = 0
94
+ total_tokens = 0
95
+ if references_dir.is_dir():
96
+ for path in sorted(references_dir.rglob("*"), key=lambda p: p.as_posix()):
97
+ if not path.is_file():
98
+ continue
99
+ text = _read_text(path)
100
+ metrics = measure(text)
101
+ rows.append(
102
+ {
103
+ "path": path.relative_to(references_dir.parent).as_posix(),
104
+ "role": "reference",
105
+ "exists": True,
106
+ **metrics,
107
+ }
108
+ )
109
+ total_words += metrics["words"]
110
+ total_tokens += metrics["tokens"]
111
+ total = {"count": len(rows), "words": total_words, "tokens": total_tokens}
112
+ return rows, total
113
+
114
+
115
+ def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> dict:
116
+ """Measure a skill package's artifacts and the shared managed section.
117
+
118
+ Returns the metrics dict (see module docstring for the shape). Missing core
119
+ artifacts are reported with exists=False and zero metrics so package_total is
120
+ always well-defined and callers can render 'N/A' rows without guessing.
121
+ """
122
+ skill_dir = Path(skill_dir)
123
+ target_files = target_files or []
124
+
125
+ files: list[dict] = []
126
+
127
+ # Core per-skill artifacts.
128
+ for name, role in CORE_ARTIFACTS:
129
+ path = skill_dir / name
130
+ if path.is_file():
131
+ metrics = measure(_read_text(path))
132
+ files.append({"path": name, "role": role, "exists": True, **metrics})
133
+ else:
134
+ files.append({"path": name, "role": role, "exists": False, "words": 0, "tokens": 0})
135
+
136
+ # references/ — per-file rows + summed total.
137
+ ref_rows, references_total = measure_reference_files(skill_dir / "references")
138
+ files.extend(ref_rows)
139
+
140
+ # package_total = snippet + SKILL.md + metadata + references_total; EXCLUDES managed section.
141
+ core_by_role = {row["role"]: row for row in files if row["role"] in {"context-snippet", "skill-md", "metadata"}}
142
+ package_total = {
143
+ "words": sum(core_by_role[r]["words"] for r in core_by_role) + references_total["words"],
144
+ "tokens": sum(core_by_role[r]["tokens"] for r in core_by_role) + references_total["tokens"],
145
+ }
146
+
147
+ # Managed section — first --target-file that actually contains a block wins.
148
+ managed_section = {"present": False, "source_file": None, "words": 0, "tokens": 0}
149
+ for tf in target_files:
150
+ tf = Path(tf)
151
+ if not tf.is_file():
152
+ continue
153
+ section = extract_managed_section(_read_text(tf))
154
+ if section is not None:
155
+ managed_section = {
156
+ "present": True,
157
+ "source_file": tf.as_posix(),
158
+ **measure(section),
159
+ }
160
+ break
161
+
162
+ return {
163
+ "status": "ok",
164
+ "skill_package": skill_dir.as_posix(),
165
+ "files": files,
166
+ "references_total": references_total,
167
+ "managed_section": managed_section,
168
+ "package_total": package_total,
169
+ }
170
+
171
+
172
+ def main(argv: list[str] | None = None) -> int:
173
+ parser = argparse.ArgumentParser(
174
+ prog="skf-count-tokens.py",
175
+ description=(
176
+ "Deterministic per-artifact word/token metrics for a skill package. Emits JSON "
177
+ "with per-file rows, references_total, the shared managed-section block, and a "
178
+ "package_total that excludes the managed section (see references/token-report.md §1). "
179
+ "tokens = len(text)//4 (SKF-wide convention); words = whitespace-split count."
180
+ ),
181
+ )
182
+ parser.add_argument(
183
+ "skill_package",
184
+ help="Resolved skill-package directory (holds context-snippet.md, SKILL.md, metadata.json, references/)",
185
+ )
186
+ parser.add_argument(
187
+ "--target-file",
188
+ action="append",
189
+ default=[],
190
+ metavar="PATH",
191
+ help="Context file (CLAUDE.md / AGENTS.md / .cursorrules) to extract the managed "
192
+ "section from; repeatable — the first file with a BEGIN..END block is measured.",
193
+ )
194
+ parser.add_argument("-o", "--output", metavar="PATH", help="Write JSON here (default: stdout)")
195
+ parser.add_argument("--verbose", action="store_true", help="Emit diagnostics to stderr")
196
+ args = parser.parse_args(argv)
197
+
198
+ skill_dir = Path(args.skill_package)
199
+ if not skill_dir.is_dir():
200
+ print(f"error: skill package directory not found: {skill_dir}", file=sys.stderr)
201
+ return 2
202
+
203
+ if args.verbose:
204
+ print(f"measuring skill package: {skill_dir}", file=sys.stderr)
205
+ if args.target_file:
206
+ print(f"managed-section candidates: {args.target_file}", file=sys.stderr)
207
+
208
+ result = count_package(skill_dir, [Path(p) for p in args.target_file])
209
+
210
+ payload = json.dumps(result, indent=2)
211
+ if args.output:
212
+ Path(args.output).write_text(payload + "\n", encoding="utf-8")
213
+ if args.verbose:
214
+ print(f"wrote {args.output}", file=sys.stderr)
215
+ else:
216
+ print(payload)
217
+
218
+ return 0
219
+
220
+
221
+ if __name__ == "__main__":
222
+ sys.exit(main())
@@ -38,6 +38,42 @@ Exit codes:
38
38
  0 found >=1 documentation source
39
39
  1 no documentation sources found (empty array)
40
40
  2 error (invalid args, gh not found, etc.)
41
+
42
+ ---------------------------------------------------------------------------
43
+ Subcommand: compare-hashes
44
+ ---------------------------------------------------------------------------
45
+
46
+ Doc-drift detection for audit-skill. Given a set of tracked doc sources
47
+ (each `{url, content_hash}`), re-fetch every URL, hash the response bytes with
48
+ the SAME fetch+sha256 primitive the compile side used, and classify each entry
49
+ as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
50
+ per-URL fetch/hash/compare/count prose at
51
+ `src/skf-audit-skill/references/step-doc-drift.md` §2-3 — the model no longer
52
+ orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
53
+ depends on whether a WebFetch tool is wired.
54
+
55
+ uv run src/shared/scripts/skf-detect-docs.py compare-hashes <source>
56
+
57
+ Input `<source>`:
58
+ - a path to a JSON file that is EITHER an array of `{url, content_hash}`
59
+ entries OR an object with a `doc_sources` array (e.g. a skill's
60
+ metadata.json), OR
61
+ - `-` to read that JSON from stdin.
62
+
63
+ Output (JSON object on stdout):
64
+ {
65
+ "changed": [{"url","old_hash","new_hash"}],
66
+ "unchanged": [{"url"}],
67
+ "fetch_failed": [{"url","old_hash","reason"}],
68
+ "skipped_null_hash": [{"url"}],
69
+ "stats": {"total_tracked","changed","unchanged",
70
+ "fetch_failed","skipped_null_hash"}
71
+ }
72
+
73
+ Each list is stably sorted by url. Stored hashes are normalized (leading
74
+ `algo:` prefix stripped) before comparison so a bare-hex writer form still
75
+ matches. Exit 0 on any well-formed input (never blocks the informational
76
+ audit); exit 2 on malformed args or JSON.
41
77
  """
42
78
 
43
79
  from __future__ import annotations
@@ -47,12 +83,13 @@ import base64
47
83
  import hashlib
48
84
  import json
49
85
  import re
86
+ import socket
50
87
  import subprocess
51
88
  import sys
52
89
  import urllib.error
53
90
  import urllib.request
54
91
  from pathlib import Path
55
- from typing import Any, Dict, List, Optional
92
+ from typing import Any, Dict, List, Optional, Tuple
56
93
 
57
94
  _GITHUB_URL_RE = re.compile(
58
95
  r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
@@ -224,6 +261,57 @@ def _fetch_and_hash(url: str) -> Optional[str]:
224
261
  return None
225
262
 
226
263
 
264
+ # Leading algorithm-name prefix on a stored hash (`sha256:`, `sha1:`, ...) so a
265
+ # bare-hex writer form and the prefixed form compare equal.
266
+ _HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
267
+
268
+
269
+ def _normalize_hash(value: Optional[str]) -> Optional[str]:
270
+ """Strip a leading `algo:` prefix from a hash so bare-hex and prefixed
271
+ forms compare equal. Returns None for non-string input. Idempotent."""
272
+ if not isinstance(value, str):
273
+ return None
274
+ return _HASH_PREFIX_RE.sub("", value, count=1)
275
+
276
+
277
+ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
278
+ """Byte-symmetric sibling of `_fetch_and_hash` that surfaces WHY a fetch
279
+ failed for drift reporting.
280
+
281
+ Uses the identical fetch+sha256 primitive as the compile side — same
282
+ `USER_AGENT`, same `_FETCH_TIMEOUT`, sha256 of the raw response bytes with
283
+ the `sha256:` prefix — so a hash produced here compares byte-for-byte
284
+ against a `content_hash` written by `_fetch_and_hash` at compile time.
285
+
286
+ Returns `(content_hash, None)` on success or `(None, reason)` on failure.
287
+ Never raises: the doc-drift audit must never abort on a bad URL.
288
+ """
289
+ if url.startswith("file://"):
290
+ local_path = url[7:]
291
+ try:
292
+ with open(local_path, "rb") as fh:
293
+ content = fh.read()
294
+ except OSError as exc:
295
+ return None, f"local read failed: {exc}"
296
+ return "sha256:" + hashlib.sha256(content).hexdigest(), None
297
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
298
+ try:
299
+ with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
300
+ content = resp.read()
301
+ except urllib.error.HTTPError as exc:
302
+ return None, f"HTTP {exc.code}"
303
+ except (socket.timeout, TimeoutError):
304
+ return None, f"timeout after {_FETCH_TIMEOUT}s"
305
+ except urllib.error.URLError as exc:
306
+ reason = getattr(exc, "reason", exc)
307
+ if isinstance(reason, (socket.timeout, TimeoutError)):
308
+ return None, f"timeout after {_FETCH_TIMEOUT}s"
309
+ return None, f"URL error: {reason}"
310
+ except Exception as exc: # never let a malformed URL crash the audit
311
+ return None, f"fetch failed: {exc}"
312
+ return "sha256:" + hashlib.sha256(content).hexdigest(), None
313
+
314
+
227
315
  # ---------------------------------------------------------------------------
228
316
  # Detection method 1 — homepageUrl
229
317
  # ---------------------------------------------------------------------------
@@ -376,11 +464,154 @@ def detect(
376
464
  return deduped
377
465
 
378
466
 
467
+ # ---------------------------------------------------------------------------
468
+ # compare-hashes subcommand — doc-drift detection for audit-skill
469
+ # ---------------------------------------------------------------------------
470
+
471
+ def _load_doc_sources(raw_text: str, source_label: str) -> List[Any]:
472
+ """Parse a compare-hashes input blob into a list of doc-source entries.
473
+
474
+ Accepts either a top-level JSON array of `{url, content_hash}` entries or a
475
+ top-level object with a `doc_sources` array (e.g. a skill's metadata.json).
476
+ An object without `doc_sources` yields an empty list (nothing tracked).
477
+
478
+ Raises ValueError on malformed JSON or an unexpected top-level shape.
479
+ """
480
+ try:
481
+ data = json.loads(raw_text)
482
+ except json.JSONDecodeError as exc:
483
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
484
+ if isinstance(data, list):
485
+ return data
486
+ if isinstance(data, dict):
487
+ entries = data.get("doc_sources")
488
+ if entries is None:
489
+ return []
490
+ if not isinstance(entries, list):
491
+ raise ValueError(f"`doc_sources` in {source_label} is not an array")
492
+ return entries
493
+ raise ValueError(
494
+ f"{source_label} must be a JSON array or an object with a "
495
+ f"`doc_sources` array; got {type(data).__name__}"
496
+ )
497
+
498
+
499
+ def compare_doc_hashes(doc_sources: List[Any]) -> Dict[str, Any]:
500
+ """Fetch/hash/compare each tracked doc source and categorize it.
501
+
502
+ Deterministic: the same entries served the same upstream bytes always
503
+ yield the same categorization. Every entry lands in exactly one bucket, so
504
+ `stats.total_tracked` == the sum of the four category counts.
505
+ """
506
+ changed: List[Dict[str, Any]] = []
507
+ unchanged: List[Dict[str, Any]] = []
508
+ fetch_failed: List[Dict[str, Any]] = []
509
+ skipped_null_hash: List[Dict[str, Any]] = []
510
+
511
+ for entry in doc_sources:
512
+ if not isinstance(entry, dict):
513
+ fetch_failed.append(
514
+ {"url": "", "old_hash": None, "reason": "invalid entry: not an object"}
515
+ )
516
+ continue
517
+ url = entry.get("url")
518
+ if not isinstance(url, str) or not url:
519
+ fetch_failed.append({
520
+ "url": url if isinstance(url, str) else "",
521
+ "old_hash": entry.get("content_hash"),
522
+ "reason": "invalid entry: missing url",
523
+ })
524
+ continue
525
+ stored = entry.get("content_hash")
526
+ if stored is None:
527
+ # No baseline hash was recorded at compile time — nothing to
528
+ # compare against, so skip the fetch entirely.
529
+ skipped_null_hash.append({"url": url})
530
+ continue
531
+ new_hash, reason = _fetch_and_hash_reason(url)
532
+ if new_hash is None:
533
+ fetch_failed.append({"url": url, "old_hash": stored, "reason": reason})
534
+ elif _normalize_hash(new_hash) == _normalize_hash(stored):
535
+ unchanged.append({"url": url})
536
+ else:
537
+ changed.append({"url": url, "old_hash": stored, "new_hash": new_hash})
538
+
539
+ changed.sort(key=lambda e: e["url"])
540
+ unchanged.sort(key=lambda e: e["url"])
541
+ fetch_failed.sort(key=lambda e: e["url"])
542
+ skipped_null_hash.sort(key=lambda e: e["url"])
543
+
544
+ total = len(changed) + len(unchanged) + len(fetch_failed) + len(skipped_null_hash)
545
+ return {
546
+ "changed": changed,
547
+ "unchanged": unchanged,
548
+ "fetch_failed": fetch_failed,
549
+ "skipped_null_hash": skipped_null_hash,
550
+ "stats": {
551
+ "total_tracked": total,
552
+ "changed": len(changed),
553
+ "unchanged": len(unchanged),
554
+ "fetch_failed": len(fetch_failed),
555
+ "skipped_null_hash": len(skipped_null_hash),
556
+ },
557
+ }
558
+
559
+
560
+ def _cmd_compare_hashes(argv: List[str]) -> int:
561
+ parser = argparse.ArgumentParser(
562
+ prog="skf-detect-docs.py compare-hashes",
563
+ description=(
564
+ "Re-fetch each tracked doc URL, hash the response bytes, and "
565
+ "compare against the stored content_hash to detect doc drift."
566
+ ),
567
+ )
568
+ parser.add_argument(
569
+ "source",
570
+ help=(
571
+ "path to a JSON file (array of {url, content_hash} entries, or an "
572
+ "object with a doc_sources array such as metadata.json), or - to "
573
+ "read that JSON from stdin"
574
+ ),
575
+ )
576
+ args = parser.parse_args(argv)
577
+
578
+ if args.source == "-":
579
+ raw = sys.stdin.read()
580
+ label = "<stdin>"
581
+ else:
582
+ path = Path(args.source)
583
+ try:
584
+ raw = path.read_text(encoding="utf-8")
585
+ except OSError as exc:
586
+ json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
587
+ sys.stderr.write("\n")
588
+ return 2
589
+ label = str(path)
590
+
591
+ try:
592
+ doc_sources = _load_doc_sources(raw, label)
593
+ except ValueError as exc:
594
+ json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
595
+ sys.stderr.write("\n")
596
+ return 2
597
+
598
+ result = compare_doc_hashes(doc_sources)
599
+ json.dump(result, sys.stdout, separators=(",", ":"))
600
+ sys.stdout.write("\n")
601
+ return 0
602
+
603
+
379
604
  # ---------------------------------------------------------------------------
380
605
  # CLI entry point
381
606
  # ---------------------------------------------------------------------------
382
607
 
383
608
  def main() -> int:
609
+ # Additive subcommand: route `compare-hashes ...` to the doc-drift path
610
+ # before the existing detect parser runs. The default (no subcommand)
611
+ # invocation `--repo-url <url> ...` is unchanged.
612
+ if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
613
+ return _cmd_compare_hashes(sys.argv[2:])
614
+
384
615
  parser = argparse.ArgumentParser(
385
616
  description="Detect documentation URLs for a GitHub repository.",
386
617
  )
@@ -58,6 +58,16 @@ Output (JSON on stdout):
58
58
  detection_source — human-readable string naming what fired (manifest
59
59
  basename, extension share, etc.)
60
60
  fallback_to_extension_frequency — bool (true when rule 10 fired)
61
+ detected_languages — ordered, deduplicated list of EVERY manifest-level
62
+ match in the same priority order the winner walk uses.
63
+ language == detected_languages[0] whenever the manifest
64
+ table fired, so a caller can auto-pick detected_languages[0]
65
+ and gate multi-language disambiguation on
66
+ len(detected_languages) > 1. Special cases: a
67
+ workspace_signal override (rule 0) is decisive and returns
68
+ a single-element [language]; the extension-frequency
69
+ fallback (rule 10) contributes [language] for a best guess
70
+ or [] when language is "unknown".
61
71
 
62
72
  Exit codes:
63
73
  0 — recommendation produced (even when language is "unknown")
@@ -201,7 +211,74 @@ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
201
211
  }
202
212
 
203
213
 
214
+ def _detected_languages(payload: dict[str, Any], winner: dict[str, Any]) -> list[str]:
215
+ """Accumulate every manifest-level match into an ordered, deduplicated list.
216
+
217
+ The walk mirrors the winner-selection priority order exactly, so
218
+ winner["language"] == detected_languages[0] whenever the manifest table
219
+ fired. Two decisive short-circuits diverge from a full walk on purpose:
220
+
221
+ * A workspace_signal override (rule 0) is authoritative — the workspace
222
+ root language wins over any nested package.json, so this returns a
223
+ single-element [winner_language] and never surfaces a spurious
224
+ multi-language gate for what workspace detection already resolved.
225
+ * When no manifest matched (rule 10 fired), the list carries the
226
+ extension-frequency best guess as a single element, or [] when the
227
+ guess was "unknown".
228
+ """
229
+ tree: list[str] = payload["tree"] # validated non-empty by _winner()
230
+
231
+ workspace_signal = payload.get("workspace_signal")
232
+ if isinstance(workspace_signal, str) and workspace_signal in _WORKSPACE_SIGNAL_LANGUAGE:
233
+ return [winner["language"]]
234
+
235
+ langs: list[str] = []
236
+
237
+ def add(lang: str) -> None:
238
+ if lang not in langs:
239
+ langs.append(lang)
240
+
241
+ # Rule 1 — package.json (tsconfig.json disambiguation)
242
+ if _has_basename(tree, "package.json"):
243
+ add("typescript" if _has_basename(tree, "tsconfig.json") else "javascript")
244
+
245
+ # Rules 2-6 / 8 / 10 — single-basename manifests (same order as _MANIFEST_RULES)
246
+ for basename, language, _source in _MANIFEST_RULES:
247
+ if _has_basename(tree, basename):
248
+ add(language)
249
+
250
+ # Rule 7b — build.gradle (Groovy) Java/Kotlin disambiguation
251
+ if _has_basename(tree, "build.gradle"):
252
+ add("kotlin" if _has_path_segment(tree, "src/main/kotlin/") else "java")
253
+
254
+ # Rules 8-9 — suffix-based (csproj, sln)
255
+ for suffix, language, _source in _SUFFIX_RULES:
256
+ if _has_suffix(tree, suffix):
257
+ add(language)
258
+
259
+ if langs:
260
+ return langs
261
+
262
+ # No manifest matched — the winner came from the extension-frequency
263
+ # fallback (rule 10). Carry its best guess, or [] when it was "unknown".
264
+ fallback_language = winner["language"]
265
+ return [] if fallback_language == "unknown" else [fallback_language]
266
+
267
+
204
268
  def detect(payload: dict[str, Any]) -> dict[str, Any]:
269
+ """Apply the documented rule walk and annotate the full manifest match set.
270
+
271
+ Returns the single-winner envelope (language/confidence/detection_source/
272
+ fallback_to_extension_frequency) unchanged, plus detected_languages[] — the
273
+ ordered, deduplicated set of every manifest-level match — so a caller can
274
+ both auto-pick and gate multi-language disambiguation off one JSON shape.
275
+ """
276
+ winner = _winner(payload)
277
+ winner["detected_languages"] = _detected_languages(payload, winner)
278
+ return winner
279
+
280
+
281
+ def _winner(payload: dict[str, Any]) -> dict[str, Any]:
205
282
  """Apply the documented rule walk. Always returns a recommendation."""
206
283
  tree = payload.get("tree")
207
284
  if not isinstance(tree, list):