bmad-module-skill-forge 1.9.0 → 2.0.1

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 (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = []
5
+ # ///
6
+ """SKF Co-mention Pairs — compute compose-mode co-mention pairs from an
7
+ architecture document with the same precision guards the prompt used to run
8
+ by hand.
9
+
10
+ `detect-integrations.md` §2 (compose branch) asks the LLM to scan an
11
+ architecture markdown document for skill-name co-mentions using three
12
+ deterministic guards:
13
+
14
+ 1. Word-boundary matching (`\\b{skill_name}\\b`, case-insensitive) — no
15
+ substring false positives (`react` must not match inside `reactive`).
16
+ 2. Section filtering — paragraphs governed by an H1/H2 header that
17
+ normalises to one of {introduction, overview, glossary, table of
18
+ contents, references, appendix, index} are excluded, and heading text
19
+ itself is never a co-mention source.
20
+ 3. Two-paragraph minimum — a pair (A, B) qualifies only when at least two
21
+ distinct body paragraphs each contain word-boundary matches for both A
22
+ and B.
23
+
24
+ Given a fixed architecture doc and skill-name set the qualifying-pair set is
25
+ fully determined, so this belongs in a deterministic helper rather than
26
+ in-prompt regex/counting. It mirrors the co-import subprocess the code-mode
27
+ branch already delegates and the `skf-pair-intersect.py` pre-pass. The LLM
28
+ still does the semantic composition (integration-section authoring, tier
29
+ inheritance, VS-verdict parsing) — the script only emits the qualifying pairs
30
+ and their evidence paragraphs.
31
+
32
+ Subcommand:
33
+ comention --doc <md-file-or-'-'> --skills <json-file-or-'-'>
34
+ --doc path to the architecture markdown document, or '-' for stdin.
35
+ --skills path to a JSON array of loaded skill names
36
+ (e.g. `["react", "express"]`), or '-' for stdin.
37
+ (--doc and --skills cannot both read from stdin.)
38
+
39
+ Emit JSON:
40
+ {
41
+ "pairs": [
42
+ {
43
+ "a": "<skill>", "b": "<skill>",
44
+ "paragraph_count": N,
45
+ "evidence": [{"header": "<governing-header-or-null>",
46
+ "excerpt": "<collapsed paragraph text>"}, ...]
47
+ },
48
+ ...
49
+ ],
50
+ "excluded_section_count": M
51
+ }
52
+
53
+ Only unordered pairs (a < b lexicographically) with paragraph_count >= 2
54
+ are emitted. `pairs[]` is sorted by paragraph_count DESC, then (a, b)
55
+ ASC — stable, mirroring skf-pair-intersect.py. `evidence[]` is in
56
+ document order. `excluded_section_count` is the number of DISTINCT
57
+ excluded H1/H2 sections (by normalised header) that governed at least
58
+ one body paragraph — a diagnostic for the caller, not a gate.
59
+
60
+ CLI examples:
61
+ uv run skf-comention-pairs.py comention --doc arch.md --skills skills.json
62
+ echo '["react","express"]' | uv run skf-comention-pairs.py comention \\
63
+ --doc arch.md --skills -
64
+
65
+ Exit codes:
66
+ 0 — operation succeeded (including: no qualifying pairs → empty list)
67
+ 1 — user error (bad JSON, missing/unreadable file, both inputs on stdin,
68
+ malformed skills array)
69
+ 2 — unexpected internal error
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import argparse
75
+ import json
76
+ import re
77
+ import sys
78
+ from pathlib import Path
79
+
80
+
81
+ # Closed exclusion set — governing H1/H2 headers that normalise to one of
82
+ # these drop their paragraphs from co-mention analysis (they typically
83
+ # enumerate all libraries without describing an integration).
84
+ EXCLUDED_HEADERS = frozenset({
85
+ "introduction",
86
+ "overview",
87
+ "glossary",
88
+ "table of contents",
89
+ "references",
90
+ "appendix",
91
+ "index",
92
+ })
93
+
94
+ # ATX header line: 1-6 leading hashes followed by at least one space/tab.
95
+ # A line like `#foo` (no space) is NOT a header per CommonMark and is treated
96
+ # as body text. Capture group 1 = hashes (level), group 2 = header text.
97
+ _HEADER_RE = re.compile(r"^(#{1,6})[ \t]+(.*)$")
98
+
99
+ _EXCERPT_LIMIT = 240
100
+
101
+
102
+ class UserError(Exception):
103
+ """A caller-facing input error → exit code 1."""
104
+
105
+
106
+ # --------------------------------------------------------------------------
107
+ # Markdown parsing
108
+ # --------------------------------------------------------------------------
109
+
110
+
111
+ def _strip_closing_hashes(text: str) -> str:
112
+ """Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`)."""
113
+ return re.sub(r"[ \t]*#+[ \t]*$", "", text)
114
+
115
+
116
+ def normalize_header(text: str) -> str:
117
+ """Normalise a header for exclusion-set comparison: lowercase, collapse
118
+ internal whitespace, strip trailing punctuation."""
119
+ t = text.strip().lower()
120
+ t = re.sub(r"\s+", " ", t).strip()
121
+ t = t.rstrip(".,:;!?").strip()
122
+ return t
123
+
124
+
125
+ def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
126
+ """Split the document into body paragraphs, each tagged with the raw text
127
+ of its governing (nearest-preceding) H1/H2 header (or None).
128
+
129
+ Paragraphs are maximal runs of consecutive non-blank, non-header lines,
130
+ separated by blank lines. Header lines of ANY level are never emitted as
131
+ paragraphs (headings are not a co-mention source). Only H1/H2 headers
132
+ update the governing header; H3-H6 leave it unchanged.
133
+ """
134
+ paragraphs: list[tuple[str | None, str]] = []
135
+ governing: str | None = None
136
+ buf: list[str] = []
137
+
138
+ def _flush() -> None:
139
+ if buf:
140
+ text = "\n".join(buf).strip()
141
+ if text:
142
+ paragraphs.append((governing, text))
143
+ buf.clear()
144
+
145
+ for raw_line in doc_text.splitlines():
146
+ m = _HEADER_RE.match(raw_line)
147
+ if m is not None:
148
+ # A header terminates the current paragraph; the header line
149
+ # itself is not body content.
150
+ _flush()
151
+ level = len(m.group(1))
152
+ header_text = _strip_closing_hashes(m.group(2)).strip()
153
+ if level <= 2:
154
+ governing = header_text if header_text else None
155
+ continue
156
+ if raw_line.strip() == "":
157
+ _flush()
158
+ continue
159
+ buf.append(raw_line)
160
+ _flush()
161
+ return paragraphs
162
+
163
+
164
+ # --------------------------------------------------------------------------
165
+ # Co-mention analysis
166
+ # --------------------------------------------------------------------------
167
+
168
+
169
+ def _make_excerpt(text: str, limit: int = _EXCERPT_LIMIT) -> str:
170
+ """Collapse whitespace and truncate for a stable, compact evidence quote."""
171
+ collapsed = re.sub(r"\s+", " ", text).strip()
172
+ if len(collapsed) > limit:
173
+ return collapsed[: limit - 3].rstrip() + "..."
174
+ return collapsed
175
+
176
+
177
+ def analyze(doc_text: str, skill_names: list[str]) -> dict:
178
+ """Compute qualifying co-mention pairs and their evidence.
179
+
180
+ Deterministic: same (doc_text, skill_names) → identical output.
181
+ """
182
+ names = sorted(set(skill_names))
183
+ patterns = {
184
+ n: re.compile(r"\b" + re.escape(n) + r"\b", re.IGNORECASE)
185
+ for n in names
186
+ }
187
+ paragraphs = parse_body_paragraphs(doc_text)
188
+
189
+ excluded_sections: set[str] = set()
190
+ # (a, b) -> list of evidence dicts, one per qualifying paragraph
191
+ pair_evidence: dict[tuple[str, str], list[dict]] = {}
192
+
193
+ for governing, text in paragraphs:
194
+ if governing is not None:
195
+ norm = normalize_header(governing)
196
+ if norm in EXCLUDED_HEADERS:
197
+ excluded_sections.add(norm)
198
+ continue
199
+ # names is sorted, so `present` is sorted → pairs are (a < b)
200
+ present = [n for n in names if patterns[n].search(text)]
201
+ if len(present) < 2:
202
+ continue
203
+ excerpt = _make_excerpt(text)
204
+ evidence_entry = {"header": governing, "excerpt": excerpt}
205
+ for i in range(len(present)):
206
+ for j in range(i + 1, len(present)):
207
+ key = (present[i], present[j])
208
+ pair_evidence.setdefault(key, []).append(evidence_entry)
209
+
210
+ pairs: list[dict] = []
211
+ for (a, b), evidence in pair_evidence.items():
212
+ if len(evidence) >= 2:
213
+ pairs.append({
214
+ "a": a,
215
+ "b": b,
216
+ "paragraph_count": len(evidence),
217
+ "evidence": evidence,
218
+ })
219
+ pairs.sort(key=lambda p: (-p["paragraph_count"], p["a"], p["b"]))
220
+
221
+ return {
222
+ "pairs": pairs,
223
+ "excluded_section_count": len(excluded_sections),
224
+ }
225
+
226
+
227
+ # --------------------------------------------------------------------------
228
+ # CLI
229
+ # --------------------------------------------------------------------------
230
+
231
+
232
+ def _read_source(source: str, label: str) -> str:
233
+ """Read text from a file path or stdin (if source == '-')."""
234
+ if source == "-":
235
+ try:
236
+ return sys.stdin.read()
237
+ except OSError as exc:
238
+ raise UserError(f"failed to read {label} from stdin: {exc}") from exc
239
+ path = Path(source)
240
+ if not path.is_file():
241
+ raise UserError(f"{label} file not found: {path}")
242
+ try:
243
+ return path.read_text(encoding="utf-8")
244
+ except OSError as exc:
245
+ raise UserError(f"failed to read {label} file {path}: {exc}") from exc
246
+
247
+
248
+ def parse_skills(raw_text: str) -> list[str]:
249
+ """Parse and validate the --skills JSON array of non-empty strings."""
250
+ try:
251
+ data = json.loads(raw_text)
252
+ except json.JSONDecodeError as exc:
253
+ raise UserError(f"malformed JSON in --skills input: {exc}") from exc
254
+ if not isinstance(data, list):
255
+ raise UserError(
256
+ "--skills input must be a JSON array of strings; "
257
+ f"got {type(data).__name__}"
258
+ )
259
+ names: list[str] = []
260
+ for idx, item in enumerate(data):
261
+ if not isinstance(item, str) or not item:
262
+ raise UserError(
263
+ f"--skills[{idx}] must be a non-empty string; got {item!r}"
264
+ )
265
+ names.append(item)
266
+ return names
267
+
268
+
269
+ def _cmd_comention(args: argparse.Namespace) -> int:
270
+ if args.doc == "-" and args.skills == "-":
271
+ raise UserError(
272
+ "--doc and --skills cannot both read from stdin ('-')"
273
+ )
274
+ # Read the file-backed input first so a single stdin source stays intact.
275
+ if args.doc == "-":
276
+ skills_text = _read_source(args.skills, "--skills")
277
+ doc_text = _read_source(args.doc, "--doc")
278
+ else:
279
+ doc_text = _read_source(args.doc, "--doc")
280
+ skills_text = _read_source(args.skills, "--skills")
281
+ skill_names = parse_skills(skills_text)
282
+
283
+ result = analyze(doc_text, skill_names)
284
+ if args.verbose:
285
+ print(
286
+ f"analyzed {len(set(skill_names))} distinct skill names; "
287
+ f"{len(result['pairs'])} qualifying pairs; "
288
+ f"{result['excluded_section_count']} excluded sections",
289
+ file=sys.stderr,
290
+ )
291
+ json.dump(result, sys.stdout, indent=2)
292
+ sys.stdout.write("\n")
293
+ return 0
294
+
295
+
296
+ def _build_parser() -> argparse.ArgumentParser:
297
+ parser = argparse.ArgumentParser(
298
+ prog="skf-comention-pairs",
299
+ description=(
300
+ "Compute compose-mode co-mention pairs from an architecture "
301
+ "document (detect-integrations.md §2 compose branch)."
302
+ ),
303
+ )
304
+ sub = parser.add_subparsers(dest="cmd", required=True)
305
+
306
+ p_cm = sub.add_parser(
307
+ "comention",
308
+ help=(
309
+ "emit qualifying co-mention pairs (>=2 body paragraphs, "
310
+ "word-boundary, section-filtered) as JSON"
311
+ ),
312
+ )
313
+ p_cm.add_argument(
314
+ "--doc",
315
+ required=True,
316
+ help="path to the architecture markdown document, or '-' for stdin",
317
+ )
318
+ p_cm.add_argument(
319
+ "--skills",
320
+ required=True,
321
+ help=(
322
+ "path to a JSON array of loaded skill names, or '-' for stdin. "
323
+ "Shape: [\"<skill>\", ...]"
324
+ ),
325
+ )
326
+ p_cm.add_argument(
327
+ "--verbose",
328
+ action="store_true",
329
+ help="print a one-line summary to stderr",
330
+ )
331
+ p_cm.set_defaults(func=_cmd_comention)
332
+
333
+ return parser
334
+
335
+
336
+ def main(argv: list[str] | None = None) -> int:
337
+ parser = _build_parser()
338
+ args = parser.parse_args(argv)
339
+ try:
340
+ return args.func(args)
341
+ except UserError as exc:
342
+ print(f"error: {exc}", file=sys.stderr)
343
+ return 1
344
+ except Exception as exc: # noqa: BLE001 — last-resort guard → exit 2
345
+ print(f"internal error: {exc}", file=sys.stderr)
346
+ return 2
347
+
348
+
349
+ if __name__ == "__main__":
350
+ raise SystemExit(main())
@@ -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())
@@ -0,0 +1,107 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Derive Assembly Shape — decide whether a brief is a whole-language reference.
6
+
7
+ create-skill's SKILL.md assembly is mostly LLM-driven prose, but the BRANCH
8
+ SELECTION — does this skill get the whole-language "Language Guide" treatment
9
+ (prose foregrounded, compiler internals demoted) or the standard
10
+ library-export layout? — must be deterministic. This helper computes that one
11
+ decision from the brief so the assembler (and the doc-fetch step) branch on a
12
+ machine value instead of grepping free-text `scope.notes`.
13
+
14
+ The gate is a single, structured, schema-validated condition: the brief's
15
+ `doc_urls` contains at least one entry with `source: "language-registry"`
16
+ (issue #432). Registry-sourced corpora are stamped ONLY by
17
+ skf-language-corpora.py, which is invoked ONLY for a whole-language reference
18
+ (a grammar_file:/tree_triad: signal) in step-auto-scope.md §6b. So the gate
19
+ fires exactly when there is registry-guaranteed prose to foreground, and never
20
+ for an ordinary library, a parser library (pest), a component-library, a
21
+ reference-app, or a docs-only brief — none of which carry a language-registry
22
+ entry. A registry-miss (N==0) whole-language reference carries no such entry
23
+ and is intentionally NOT gated: there is no canonical prose to foreground, so
24
+ it keeps the standard layout (and its scope.notes DEGRADED caveat stands).
25
+
26
+ Gating on the structured `source` field — not the operator-editable
27
+ `scope.notes` substring — means a hand-edited or interactive brief cannot
28
+ silently disable (or misfire) the override.
29
+
30
+ CLI:
31
+ uv run skf-derive-assembly-shape.py <path/to/skill-brief.yaml>
32
+ cat brief.yaml | uv run skf-derive-assembly-shape.py -
33
+
34
+ Output (JSON on stdout):
35
+ {
36
+ "assembly_shape": "whole-language-reference" | "standard",
37
+ "gate_signal": "language-registry" | null
38
+ }
39
+
40
+ Exit codes:
41
+ 0 success (shape emitted)
42
+ 2 error (missing/unreadable/invalid brief)
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import sys
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ import yaml
54
+
55
+
56
+ def derive_assembly_shape(brief: dict[str, Any]) -> dict[str, Any]:
57
+ """Return the assembly-shape decision for a parsed brief."""
58
+ doc_urls = brief.get("doc_urls")
59
+ if isinstance(doc_urls, list):
60
+ for entry in doc_urls:
61
+ if isinstance(entry, dict) and entry.get("source") == "language-registry":
62
+ return {
63
+ "assembly_shape": "whole-language-reference",
64
+ "gate_signal": "language-registry",
65
+ }
66
+ return {"assembly_shape": "standard", "gate_signal": None}
67
+
68
+
69
+ def _die(message: str) -> None:
70
+ json.dump({"error": message}, sys.stderr)
71
+ sys.stderr.write("\n")
72
+ sys.exit(2)
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ parser = argparse.ArgumentParser(
77
+ prog="skf-derive-assembly-shape",
78
+ description="Decide whether a skill brief is a whole-language reference.",
79
+ )
80
+ parser.add_argument(
81
+ "path",
82
+ help="path to skill-brief.yaml; pass `-` to read YAML from stdin",
83
+ )
84
+ args = parser.parse_args(argv)
85
+
86
+ if args.path == "-":
87
+ text = sys.stdin.read()
88
+ else:
89
+ p = Path(args.path)
90
+ if not p.is_file():
91
+ _die(f"Brief not found at {p.as_posix()}")
92
+ text = p.read_text(encoding="utf-8")
93
+
94
+ try:
95
+ brief = yaml.safe_load(text)
96
+ except yaml.YAMLError as exc:
97
+ _die(f"Brief is not valid YAML: {exc}")
98
+ if not isinstance(brief, dict):
99
+ _die("Brief must be a YAML mapping")
100
+
101
+ json.dump(derive_assembly_shape(brief), sys.stdout)
102
+ sys.stdout.write("\n")
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())