bmad-module-skill-forge 2.2.0 → 4.0.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 (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,5 +1,5 @@
1
1
  # /// script
2
- # requires-python = ">=3.9"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Detect Docs — discover documentation URLs for a GitHub repository.
@@ -48,7 +48,7 @@ Doc-drift detection for audit-skill. Given a set of tracked doc sources
48
48
  the SAME fetch+sha256 primitive the compile side used, and classify each entry
49
49
  as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
50
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
51
+ `src/skf-audit-skill/references/doc-drift.md` §2-3: the model no longer
52
52
  orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
53
53
  depends on whether a WebFetch tool is wired.
54
54
 
@@ -75,6 +75,30 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
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
77
 
78
+ ---------------------------------------------------------------------------
79
+ Subcommand: refresh-hashes
80
+ ---------------------------------------------------------------------------
81
+
82
+ Records the new hashes of the documents a `compare-hashes` run found
83
+ changed, once update-skill has re-read them (update-skill write.md §2), so
84
+ the next comparison starts from the documents this update used.
85
+
86
+ uv run src/shared/scripts/skf-detect-docs.py refresh-hashes \\
87
+ <metadata.json> --compare <compare-hashes output>
88
+
89
+ Each `doc_sources[]` entry whose url is in the comparison's `changed[]`
90
+ takes that entry's `new_hash` as its `content_hash` and the current UTC
91
+ time as its `recorded_at`; every other entry and every other key of the
92
+ file stays as it was. The file is rewritten through a temporary file and
93
+ one rename.
94
+
95
+ Output (JSON object on stdout):
96
+ {"metadata": <path>, "refreshed": [urls], "not_found": [changed urls
97
+ that metadata.json does not track]}
98
+
99
+ Exit 0 once written; exit 2 on malformed args, JSON that is not the shape
100
+ named, or a file that cannot be read or written.
101
+
78
102
  ---------------------------------------------------------------------------
79
103
  Subcommand: hash-urls
80
104
  ---------------------------------------------------------------------------
@@ -110,20 +134,158 @@ entry without a usable url string is dropped and counted under
110
134
  instead of reporting drift. `doc_sources` is already in the metadata.json
111
135
  schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
112
136
  well-formed input; exit 2 on malformed args or JSON.
137
+
138
+ ---------------------------------------------------------------------------
139
+ Subcommand: readme-entry
140
+ ---------------------------------------------------------------------------
141
+
142
+ The README entry create-skill step 5a adds to `doc_sources[]` when detection
143
+ found none. The helper picks the README, builds its URL and hashes it with
144
+ the `hash-urls` primitive, so the model never parses the repository or
145
+ builds the URL by hand.
146
+
147
+ uv run src/shared/scripts/skf-detect-docs.py readme-entry \\
148
+ --source-repo <repo> --ref <ref> [--local-root <path>]
149
+
150
+ Input:
151
+ --source-repo the brief's source_repo
152
+ --ref the ref the skill was built from: a tag, branch or commit,
153
+ `HEAD` for the default branch (also for an empty, `null` or
154
+ `none` value), `local` for a local source
155
+ --local-root the folder the source was read from, when it is a local
156
+ folder (a local source, or the tree create-skill read a
157
+ remote one into); the README is picked from its files
158
+
159
+ A local source (`--ref local`) records `file://` and the absolute path of
160
+ its README, with `/` separators and no percent-encoding, as
161
+ `_fetch_and_hash` reads it back; its folder is --local-root, else
162
+ --source-repo. A GitHub source (an https, ssh or git URL, the scp-like
163
+ `git@github.com:owner/repo`, or the `owner/repo` shorthand; a trailing
164
+ `.git` is dropped) records the raw file at --ref:
165
+ `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<README>`. The
166
+ README is picked from the top-level files of --local-root, or, without a
167
+ local folder, from the repository's top level at --ref through the GitHub
168
+ API: `README.md` in any letter case first, then a README with no language
169
+ suffix (`README.rst`, `README.markdown`, `README`), then the first other
170
+ README in name order (a translation such as `README-ja.md`, which sorts
171
+ before `README.md`). With none listed it is `README.md`. Any other source
172
+ gets no entry.
173
+
174
+ Output (JSON object on stdout): the `hash-urls` object for the one URL,
175
+ with `detected_via: "readme_always"` on its entry, plus
176
+ "skip_reason": null | "not-github" (no entry: a source on another host)
177
+
178
+ Exit 0 once JSON is printed; exit 2 on malformed args.
179
+
180
+ ---------------------------------------------------------------------------
181
+ Subcommand: page-metrics
182
+ ---------------------------------------------------------------------------
183
+
184
+ create-skill step 3c fetches each `doc_urls` page and looks for its
185
+ subpages when the page is a documentation root with little API content of
186
+ its own (a Mintlify, Docusaurus, ReadTheDocs or GitBook landing page). This
187
+ subcommand measures a fetched page, saved as markdown, so the step reads the
188
+ decision instead of counting words and link lines by eye.
189
+
190
+ uv run src/shared/scripts/skf-detect-docs.py page-metrics \\
191
+ --url <url> <file>
192
+
193
+ Input: --url is the page's URL; <file> is its markdown, or `-` for stdin.
194
+
195
+ Output (JSON object on stdout):
196
+ {
197
+ "url": "<url>",
198
+ "path_segments": N, # non-empty segments of the URL path
199
+ "root_like": bool, # N <= 1, or the path ends in `/`, `/index`
200
+ # or `/index.html`
201
+ "code_fences": N, # fenced code blocks (``` or ~~~)
202
+ "table_rows": N, # table separator rows (`|---|`), one a table
203
+ "signature_hits": N, # `def `, `function `, `fn `, `func `,
204
+ # `export ` as words
205
+ "link_lines": N, # non-empty lines holding only markdown links
206
+ "non_empty_lines": N,
207
+ "link_line_ratio": 0.0-1.0, # link_lines / non_empty_lines, 4 places
208
+ "word_count": N, # words, link targets left out
209
+ "trigger1": bool, # root_like, no code fence, table row or
210
+ # signature, and link_line_ratio > 0.7
211
+ "trigger2": bool, # root_like and word_count < 2000
212
+ "discover_subpages": bool # trigger1 or trigger2
213
+ }
214
+
215
+ A link line is a line whose text, once its list marker, quote marker or
216
+ heading marks and its `[text](url)` links are taken out, holds no letter or
217
+ digit. Exit 0 once JSON is printed; exit 2 on malformed args or an input
218
+ that cannot be read or decoded as UTF-8.
219
+
220
+ ---------------------------------------------------------------------------
221
+ Subcommand: filter-urls
222
+ ---------------------------------------------------------------------------
223
+
224
+ The subpages step 3c may fetch for a root page: the URLs a site map, a
225
+ sitemap.xml or a crawl found, cut to the root page's own site.
226
+
227
+ uv run src/shared/scripts/skf-detect-docs.py filter-urls \\
228
+ --root <url> <source>
229
+
230
+ Input `<source>` (a file, or `-` for stdin), read as UTF-8 and gunzipped
231
+ first when it is gzip compressed (a `.xml.gz` sitemap):
232
+ - a sitemap.xml (its `<loc>` values), or
233
+ - a JSON array of URL strings or `{url, title?}` objects, or an object
234
+ with such an array under `links` or `urls` (a map tool's result), or
235
+ - one URL per line.
236
+ A URL given as a path from the site root (`/docs/api`) is read against
237
+ the root URL.
238
+
239
+ A URL is kept when it is http or https, on the root URL's registrable
240
+ domain (any subdomain of it), not the root page itself, not a repeat once
241
+ its `#fragment` is dropped (scheme and host compared in any letter case),
242
+ and has no path segment, less its file extension, that is one of
243
+ SUBPAGE_EXCLUDED_WORDS (`blog`, `changelog`, `pricing`, `about`,
244
+ `careers`): such a segment names a section with no API content, while a
245
+ longer one (`about-the-rest-api`) can head a section of API pages. The
246
+ registrable domain is worked out without a public suffix list: the last two
247
+ labels of the host, or the last three when the last two are a hosting
248
+ suffix (HOSTED_SUFFIXES: `github.io`, `readthedocs.io` and the like, where
249
+ each subdomain is another owner's site) or a country-code second level
250
+ (`co.uk`, `com.au`). An IP address or a one-label host is its own domain.
251
+
252
+ Output (JSON object on stdout):
253
+ {
254
+ "root": "<url>",
255
+ "registrable_domain": "example.com",
256
+ "kept": [{"url", "title": "..." | null,
257
+ "terms": ["api", "reference", ...]}], # input order
258
+ "sitemaps": ["<url>", ...], # same-site .xml / .xml.gz URLs
259
+ "stats": {"input", "kept", "other_domain", "excluded_path",
260
+ "not_http", "duplicate", "root", "sitemap"}
261
+ }
262
+
263
+ `terms` lists the API words (API_TERMS) the URL path or title holds, to
264
+ help choose the most relevant subpages; choosing them is the caller's call.
265
+ A same-site URL ending in `.xml` or `.xml.gz` is a sitemap, not a page (a
266
+ sitemap index lists them): it goes to `sitemaps`, for the caller to fetch
267
+ and filter in turn.
268
+ Exit 0 once JSON is printed; exit 2 on malformed args, an input that
269
+ cannot be read or decoded as UTF-8, malformed JSON or a root URL that is
270
+ not http or https.
113
271
  """
114
272
 
115
273
  from __future__ import annotations
116
274
 
117
275
  import argparse
118
276
  import base64
277
+ import gzip
119
278
  import hashlib
120
279
  import json
280
+ import os
121
281
  import re
122
282
  import socket
123
283
  import subprocess
124
284
  import sys
125
285
  import urllib.error
286
+ import urllib.parse
126
287
  import urllib.request
288
+ import zlib
127
289
  from datetime import datetime, timezone
128
290
  from pathlib import Path
129
291
  from typing import Any, Dict, List, Optional, Tuple
@@ -647,6 +809,74 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
647
809
  return 0
648
810
 
649
811
 
812
+ # ---------------------------------------------------------------------------
813
+ # Subcommand: refresh-hashes: record the hashes compare-hashes found changed
814
+ # ---------------------------------------------------------------------------
815
+
816
+ def refresh_doc_hashes(
817
+ metadata: Dict[str, Any], comparison: Dict[str, Any], recorded_at: Optional[str] = None
818
+ ) -> Tuple[Dict[str, Any], List[str], List[str]]:
819
+ """(metadata with the changed documents' new hashes, refreshed urls, urls it does not track).
820
+
821
+ Raises ValueError when `doc_sources` or `changed` is not an array.
822
+ """
823
+ entries = metadata.get("doc_sources")
824
+ if entries is None:
825
+ entries = []
826
+ if not isinstance(entries, list):
827
+ raise ValueError("`doc_sources` in the metadata is not an array")
828
+ changed = comparison.get("changed")
829
+ if not isinstance(changed, list):
830
+ raise ValueError("the comparison has no `changed` array: is it compare-hashes output?")
831
+ new_hashes = {
832
+ e["url"]: e["new_hash"] for e in changed
833
+ if isinstance(e, dict) and isinstance(e.get("url"), str) and isinstance(e.get("new_hash"), str)
834
+ }
835
+ stamp = recorded_at or datetime.now(timezone.utc).isoformat()
836
+ refreshed: List[str] = []
837
+ out: List[Any] = []
838
+ for entry in entries:
839
+ if isinstance(entry, dict) and entry.get("url") in new_hashes:
840
+ entry = {**entry, "content_hash": new_hashes[entry["url"]], "recorded_at": stamp}
841
+ refreshed.append(entry["url"])
842
+ out.append(entry)
843
+ not_found = sorted(url for url in new_hashes if url not in refreshed)
844
+ return {**metadata, "doc_sources": out}, refreshed, not_found
845
+
846
+
847
+ def _cmd_refresh_hashes(argv: List[str]) -> int:
848
+ parser = argparse.ArgumentParser(
849
+ prog="skf-detect-docs.py refresh-hashes",
850
+ description=(
851
+ "Record in metadata.json's doc_sources the new content_hash of each "
852
+ "document a compare-hashes run found changed."
853
+ ),
854
+ )
855
+ parser.add_argument("metadata", help="the skill's metadata.json, rewritten in place")
856
+ parser.add_argument("--compare", required=True, help="the compare-hashes output for that metadata.json")
857
+ args = parser.parse_args(argv)
858
+
859
+ path = Path(args.metadata)
860
+ tmp = path.with_name(f".{path.name}.skf-{os.getpid()}-tmp")
861
+ try:
862
+ metadata = json.loads(path.read_text(encoding="utf-8"))
863
+ comparison = json.loads(Path(args.compare).read_text(encoding="utf-8"))
864
+ if not isinstance(metadata, dict) or not isinstance(comparison, dict):
865
+ raise ValueError("metadata.json and the comparison must each hold a JSON object")
866
+ updated, refreshed, not_found = refresh_doc_hashes(metadata, comparison)
867
+ tmp.write_bytes((json.dumps(updated, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
868
+ os.replace(tmp, path)
869
+ except (OSError, ValueError) as exc:
870
+ if tmp.exists():
871
+ tmp.unlink()
872
+ json.dump({"error": str(exc), "code": "REFRESH_FAILED"}, sys.stderr)
873
+ sys.stderr.write("\n")
874
+ return 2
875
+ json.dump({"metadata": path.as_posix(), "refreshed": refreshed, "not_found": not_found}, sys.stdout)
876
+ sys.stdout.write("\n")
877
+ return 0
878
+
879
+
650
880
  # ---------------------------------------------------------------------------
651
881
  # Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
652
882
  # ---------------------------------------------------------------------------
@@ -775,6 +1005,460 @@ def _cmd_hash_urls(argv: List[str]) -> int:
775
1005
  return 0
776
1006
 
777
1007
 
1008
+ # ---------------------------------------------------------------------------
1009
+ # Subcommand: readme-entry, the README entry create-skill step 5a adds
1010
+ # ---------------------------------------------------------------------------
1011
+
1012
+ # `doc_sources[].detected_via` of that entry. Listed in
1013
+ # skf-create-skill/assets/skill-sections.md.
1014
+ README_ALWAYS_DETECTED_VIA = "readme_always"
1015
+
1016
+ # Serves a GitHub repository's files as they are, one path per ref.
1017
+ RAW_GITHUB = "https://raw.githubusercontent.com"
1018
+
1019
+ # A GitHub repository as a brief's source_repo names it: an https, ssh or git
1020
+ # URL, the scp-like `git@github.com:owner/repo`, `github.com/owner/repo`, or
1021
+ # the `owner/repo` shorthand. A trailing `.git` or `/` is not part of the
1022
+ # name, which may itself hold dots (`vercel/next.js`).
1023
+ _GITHUB_REPO_RE = re.compile(
1024
+ r"^(?:(?:https?|ssh|git)://(?:[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?/"
1025
+ r"|[^@/\s:]+@github\.com:"
1026
+ r"|(?:www\.)?github\.com/)?"
1027
+ r"([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9._-]+?)(?:\.git)?/?$",
1028
+ re.IGNORECASE,
1029
+ )
1030
+
1031
+ # The README a folder shows, in the order it is picked: README.md in any
1032
+ # letter case, then a README with no language suffix (README.rst,
1033
+ # README.markdown, README), then any other README, such as a translation
1034
+ # (README-ja.md), which sorts before README.md by name.
1035
+ _README_PREFERENCE = (
1036
+ re.compile(r"^readme\.md$", re.IGNORECASE),
1037
+ re.compile(r"^readme(?:\.[A-Za-z0-9]+)?$", re.IGNORECASE),
1038
+ re.compile(r"^readme", re.IGNORECASE),
1039
+ )
1040
+
1041
+
1042
+ def parse_github_repo(source_repo: str) -> Optional[Tuple[str, str]]:
1043
+ """(owner, repo) of the GitHub repository `source_repo` names, else None."""
1044
+ m = _GITHUB_REPO_RE.match((source_repo or "").strip())
1045
+ if not m or m.group(2) in (".", ".."):
1046
+ return None
1047
+ return m.group(1), m.group(2)
1048
+
1049
+
1050
+ def pick_readme(names: List[str]) -> Optional[str]:
1051
+ """The README among a folder's top-level file names, by _README_PREFERENCE."""
1052
+ ordered = sorted(names)
1053
+ for pattern in _README_PREFERENCE:
1054
+ for name in ordered:
1055
+ if pattern.match(name):
1056
+ return name
1057
+ return None
1058
+
1059
+
1060
+ def _local_files(folder: Path) -> List[str]:
1061
+ try:
1062
+ return [p.name for p in folder.iterdir() if p.is_file()]
1063
+ except OSError:
1064
+ return []
1065
+
1066
+
1067
+ def _github_files(owner: str, repo: str, ref: str) -> List[str]:
1068
+ """Names of the files at the top of the repository at `ref`; [] when gh cannot list them."""
1069
+ query = "" if ref == "HEAD" else "?ref=" + urllib.parse.quote(ref, safe="")
1070
+ raw = _run_gh(["api", f"repos/{owner}/{repo}/contents{query}",
1071
+ "--jq", '.[] | select(.type == "file") | .name'])
1072
+ return raw.splitlines() if raw else []
1073
+
1074
+
1075
+ def readme_url(source_repo: str, ref: str, local_root: Optional[str] = None) -> Optional[str]:
1076
+ """The URL of the README entry, or None for a source that is neither local nor on GitHub.
1077
+
1078
+ See the readme-entry section of the module docstring.
1079
+ """
1080
+ ref = (ref or "").strip()
1081
+ if ref.lower() in ("", "null", "none", "head"):
1082
+ ref = "HEAD"
1083
+ root = Path(local_root.strip()) if local_root and local_root.strip() else None
1084
+ if ref.lower() == "local":
1085
+ # Written as _fetch_and_hash reads it back: `file://` and the path, never percent-encoded.
1086
+ folder = Path(os.path.abspath(root if root is not None else (source_repo or "").strip()))
1087
+ return "file://" + (folder / (pick_readme(_local_files(folder)) or "README.md")).as_posix()
1088
+ parsed = parse_github_repo(source_repo)
1089
+ if parsed is None:
1090
+ return None
1091
+ owner, repo = parsed
1092
+ names = _local_files(root) if root is not None and root.is_dir() else _github_files(owner, repo, ref)
1093
+ name = pick_readme(names) or "README.md"
1094
+ return (f"{RAW_GITHUB}/{owner}/{repo}/{urllib.parse.quote(ref, safe='/@+')}/"
1095
+ f"{urllib.parse.quote(name, safe='')}")
1096
+
1097
+
1098
+ def readme_entry(source_repo: str, ref: str, local_root: Optional[str] = None,
1099
+ recorded_at: Optional[str] = None) -> Dict[str, Any]:
1100
+ """The readme-entry output: hash_urls for the README URL, its entry marked readme_always."""
1101
+ url = readme_url(source_repo, ref, local_root)
1102
+ if url is None:
1103
+ result = hash_urls([], recorded_at)
1104
+ result["skip_reason"] = "not-github"
1105
+ return result
1106
+ result = hash_urls([url], recorded_at)
1107
+ for entry in result["doc_sources"]:
1108
+ entry["detected_via"] = README_ALWAYS_DETECTED_VIA
1109
+ result["skip_reason"] = None
1110
+ return result
1111
+
1112
+
1113
+ def _cmd_readme_entry(argv: List[str]) -> int:
1114
+ parser = argparse.ArgumentParser(
1115
+ prog="skf-detect-docs.py readme-entry",
1116
+ description=(
1117
+ "Pick a skill source's README and emit its doc_sources entry "
1118
+ "(detected_via: readme_always): the raw GitHub file at --ref, or a "
1119
+ "file:// URL for a local source, hashed as compare-hashes hashes it."
1120
+ ),
1121
+ )
1122
+ parser.add_argument("--source-repo", required=True, help="the brief's source_repo")
1123
+ parser.add_argument(
1124
+ "--ref", required=True,
1125
+ help="the ref the skill was built from: a tag, branch or commit, HEAD "
1126
+ "for the default branch, local for a local source",
1127
+ )
1128
+ parser.add_argument(
1129
+ "--local-root", default=None,
1130
+ help="the folder the source was read from, when it is a local folder; "
1131
+ "the README is picked from its files",
1132
+ )
1133
+ args = parser.parse_args(argv)
1134
+
1135
+ result = readme_entry(args.source_repo, args.ref, args.local_root)
1136
+ json.dump(result, sys.stdout, separators=(",", ":"))
1137
+ sys.stdout.write("\n")
1138
+ return 0
1139
+
1140
+
1141
+ # ---------------------------------------------------------------------------
1142
+ # Subcommand: page-metrics, the root-page test create-skill step 3c runs
1143
+ # ---------------------------------------------------------------------------
1144
+
1145
+ # A page is a navigation hub when more than this share of its non-empty
1146
+ # lines are links (trigger 1), or a landing page when it has fewer words
1147
+ # than ROOT_WORD_LIMIT (trigger 2).
1148
+ LINK_RATIO_THRESHOLD = 0.7
1149
+ ROOT_WORD_LIMIT = 2000
1150
+
1151
+ _FENCE_OPEN_RE = re.compile(r"^\s*(`{3,}|~{3,})")
1152
+ _SIGNATURE_RE = re.compile(r"(?<![A-Za-z0-9_])(?:def|function|fn|func|export) ")
1153
+ # A markdown link that is not an image: `[text](target)`, the target
1154
+ # optionally followed by a quoted title.
1155
+ _LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(\s*[^)\s]*(?:\s+\"[^\"]*\")?\s*\)")
1156
+ _IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
1157
+ _LINE_MARKER_RE = re.compile(r"^(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?")
1158
+ _WORD_RE = re.compile(r"[^\W_]+(?:['’-][^\W_]+)*")
1159
+
1160
+
1161
+ def _table_separator(line: str) -> bool:
1162
+ """A markdown table's separator row: pipes, dashes, colons and spaces
1163
+ only, with at least one pipe and three dashes in a row."""
1164
+ text = line.strip()
1165
+ return "|" in text and "---" in text and set(text) <= set("|-: \t")
1166
+
1167
+
1168
+ def _link_line(line: str) -> bool:
1169
+ """True for a line that holds markdown links and no other text."""
1170
+ text = _LINE_MARKER_RE.sub("", line.strip(), count=1)
1171
+ if not _LINK_RE.search(text):
1172
+ return False
1173
+ return not re.search(r"[^\W_]", _LINK_RE.sub("", text))
1174
+
1175
+
1176
+ def _root_like(path: str) -> Tuple[int, bool]:
1177
+ """(path segments, root-like) for a URL path."""
1178
+ segments = [s for s in path.split("/") if s]
1179
+ return len(segments), len(segments) <= 1 or path.endswith(("/", "/index", "/index.html"))
1180
+
1181
+
1182
+ def page_metrics(url: str, markdown: str) -> Dict[str, Any]:
1183
+ """Measure a fetched page for the step 3c subpage decision."""
1184
+ segments, root_like = _root_like(urllib.parse.urlsplit(url.strip()).path)
1185
+ lines = markdown.splitlines()
1186
+ fences = 0
1187
+ open_fence: Optional[str] = None
1188
+ for line in lines:
1189
+ m = _FENCE_OPEN_RE.match(line)
1190
+ if not m:
1191
+ continue
1192
+ mark = m.group(1)
1193
+ if open_fence is None:
1194
+ open_fence = mark
1195
+ fences += 1
1196
+ elif mark[0] == open_fence[0] and len(mark) >= len(open_fence):
1197
+ open_fence = None
1198
+ table_rows = sum(1 for line in lines if _table_separator(line))
1199
+ signature_hits = len(_SIGNATURE_RE.findall(markdown))
1200
+ non_empty = [line for line in lines if line.strip()]
1201
+ link_lines = sum(1 for line in non_empty if _link_line(line))
1202
+ ratio = link_lines / len(non_empty) if non_empty else 0.0
1203
+ visible = _LINK_RE.sub(lambda m: " " + m.group(1) + " ", _IMAGE_RE.sub(lambda m: " " + m.group(1) + " ", markdown))
1204
+ words = len(_WORD_RE.findall(visible))
1205
+ trigger1 = (root_like and fences == 0 and table_rows == 0 and signature_hits == 0
1206
+ and ratio > LINK_RATIO_THRESHOLD)
1207
+ trigger2 = root_like and words < ROOT_WORD_LIMIT
1208
+ return {
1209
+ "url": url,
1210
+ "path_segments": segments,
1211
+ "root_like": root_like,
1212
+ "code_fences": fences,
1213
+ "table_rows": table_rows,
1214
+ "signature_hits": signature_hits,
1215
+ "link_lines": link_lines,
1216
+ "non_empty_lines": len(non_empty),
1217
+ "link_line_ratio": round(ratio, 4),
1218
+ "word_count": words,
1219
+ "trigger1": trigger1,
1220
+ "trigger2": trigger2,
1221
+ "discover_subpages": trigger1 or trigger2,
1222
+ }
1223
+
1224
+
1225
+ def _read_source(source: str) -> Tuple[Optional[str], Optional[str]]:
1226
+ """(text, None) of a file or of stdin for `-`, gunzipped first when it is
1227
+ gzip compressed (a `.xml.gz` sitemap); (None, error) when it cannot be
1228
+ read or decoded as UTF-8."""
1229
+ label = "<stdin>" if source == "-" else source
1230
+ try:
1231
+ if source == "-":
1232
+ stream = getattr(sys.stdin, "buffer", None) # an in-process test double has none
1233
+ data = stream.read() if stream is not None else sys.stdin.read().encode("utf-8")
1234
+ else:
1235
+ data = Path(source).read_bytes()
1236
+ if data[:2] == b"\x1f\x8b":
1237
+ data = gzip.decompress(data)
1238
+ return data.decode("utf-8-sig"), None
1239
+ except (OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
1240
+ return None, f"cannot read {label}: {exc}"
1241
+
1242
+
1243
+ def _doc_section(name: str) -> str:
1244
+ """The module docstring's section on one subcommand, shown by its --help."""
1245
+ m = re.search(rf"^Subcommand: {re.escape(name)}\n-+\n(.*?)(?=^-{{10,}}\n|\Z)", __doc__ or "", re.S | re.M)
1246
+ return m.group(1).strip() if m else ""
1247
+
1248
+
1249
+ def _cmd_page_metrics(argv: List[str]) -> int:
1250
+ parser = argparse.ArgumentParser(
1251
+ prog="skf-detect-docs.py page-metrics",
1252
+ description=(
1253
+ "Measure a fetched documentation page (saved as markdown) and say "
1254
+ "whether create-skill step 3c should look for its subpages."
1255
+ ),
1256
+ epilog=_doc_section("page-metrics"),
1257
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1258
+ )
1259
+ parser.add_argument("--url", required=True, help="the URL the page was fetched from")
1260
+ parser.add_argument("source", help="the page's markdown file, or - to read it from stdin")
1261
+ args = parser.parse_args(argv)
1262
+
1263
+ text, error = _read_source(args.source)
1264
+ if text is None:
1265
+ json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
1266
+ sys.stderr.write("\n")
1267
+ return 2
1268
+ json.dump(page_metrics(args.url, text), sys.stdout, separators=(",", ":"))
1269
+ sys.stdout.write("\n")
1270
+ return 0
1271
+
1272
+
1273
+ # ---------------------------------------------------------------------------
1274
+ # Subcommand: filter-urls, the subpages step 3c may fetch for a root page
1275
+ # ---------------------------------------------------------------------------
1276
+
1277
+ # Multi-label suffixes a documentation site is often hosted under: each name
1278
+ # directly below one of them is another owner's site
1279
+ # (`project.readthedocs.io` and `other.readthedocs.io` are two sites). A
1280
+ # small stand-in for the public suffix list, which the script does not ship.
1281
+ HOSTED_SUFFIXES = frozenset({
1282
+ "github.io", "gitlab.io", "codeberg.page", "readthedocs.io", "readthedocs-hosted.com",
1283
+ "netlify.app", "vercel.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com",
1284
+ "herokuapp.com", "gitbook.io", "mintlify.app", "azurewebsites.net", "cloudfront.net",
1285
+ "surge.sh", "fly.dev", "onrender.com", "blogspot.com", "appspot.com", "hf.space",
1286
+ "streamlit.app", "glitch.me", "sourceforge.io",
1287
+ })
1288
+ # Second-level labels country-code domains register names under (`co.uk`,
1289
+ # `com.au`): the registrable name is one label further left.
1290
+ _CCTLD_SECOND_LEVELS = frozenset({
1291
+ "ac", "co", "com", "edu", "go", "gob", "gov", "ltd", "mil", "ne", "net", "nom", "or", "org", "plc",
1292
+ })
1293
+ # Path segments that name a page with no API content.
1294
+ SUBPAGE_EXCLUDED_WORDS = ("blog", "changelog", "pricing", "about", "careers")
1295
+ # Words in a URL path or title that point at API documentation. A word
1296
+ # matches when it starts with one (`configuration`, `methods`).
1297
+ API_TERMS = ("api", "reference", "quickstart", "setup", "config", "getting-started", "guide", "sdk",
1298
+ "method", "function")
1299
+
1300
+ _LOC_RE = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.IGNORECASE)
1301
+ _IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
1302
+
1303
+
1304
+ def registrable_domain(host: str) -> str:
1305
+ """The registrable domain of a host name, by the heuristic in the
1306
+ filter-urls section of the module docstring."""
1307
+ host = (host or "").strip().lower().rstrip(".")
1308
+ labels = host.split(".")
1309
+ if ":" in host or _IPV4_RE.match(host) or len(labels) <= 2:
1310
+ return host
1311
+ last_two = ".".join(labels[-2:])
1312
+ if last_two in HOSTED_SUFFIXES or (len(labels[-1]) == 2 and labels[-2] in _CCTLD_SECOND_LEVELS):
1313
+ return ".".join(labels[-3:])
1314
+ return last_two
1315
+
1316
+
1317
+ def _excluded_path(path: str) -> bool:
1318
+ """True when a path segment, less its file extension, is one of SUBPAGE_EXCLUDED_WORDS."""
1319
+ for segment in path.lower().split("/"):
1320
+ stem = segment.rsplit(".", 1)[0] if "." in segment else segment
1321
+ if stem in SUBPAGE_EXCLUDED_WORDS:
1322
+ return True
1323
+ return False
1324
+
1325
+
1326
+ def _api_terms(path: str, title: str) -> List[str]:
1327
+ """The API_TERMS a URL path or title holds, as whole words (a word may go on: `configuration`)."""
1328
+ words = re.findall(r"[a-z0-9]+", (path + " " + title).lower())
1329
+ found = []
1330
+ for term in API_TERMS:
1331
+ parts = term.split("-")
1332
+ for i in range(len(words) - len(parts) + 1):
1333
+ head = words[i:i + len(parts)]
1334
+ if head[:-1] == parts[:-1] and head[-1].startswith(parts[-1]):
1335
+ found.append(term)
1336
+ break
1337
+ return found
1338
+
1339
+
1340
+ def _candidate_urls(text: str, source_label: str) -> List[Tuple[str, Optional[str]]]:
1341
+ """(url, title) pairs from a sitemap, a JSON list or map result, or one URL per line.
1342
+
1343
+ Raises ValueError on malformed JSON or a JSON shape with no URL list.
1344
+ """
1345
+ stripped = text.lstrip()
1346
+ if stripped.startswith("<"):
1347
+ return [(m.group(1).replace("&amp;", "&"), None) for m in _LOC_RE.finditer(text)]
1348
+ if stripped.startswith(("[", "{")):
1349
+ try:
1350
+ data = json.loads(text)
1351
+ except json.JSONDecodeError as exc:
1352
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
1353
+ if isinstance(data, dict):
1354
+ items = next((data[k] for k in ("links", "urls") if isinstance(data.get(k), list)), None)
1355
+ if items is None:
1356
+ raise ValueError(f"{source_label} holds no `links` or `urls` array")
1357
+ data = items
1358
+ if not isinstance(data, list):
1359
+ raise ValueError(f"{source_label} must be a JSON array of URLs")
1360
+ out: List[Tuple[str, Optional[str]]] = []
1361
+ for item in data:
1362
+ if isinstance(item, str):
1363
+ out.append((item, None))
1364
+ elif isinstance(item, dict) and isinstance(item.get("url"), str):
1365
+ title = item.get("title")
1366
+ out.append((item["url"], title if isinstance(title, str) else None))
1367
+ return out
1368
+ return [(line.strip(), None) for line in text.splitlines() if line.strip()]
1369
+
1370
+
1371
+ def _url_key(parts: urllib.parse.SplitResult) -> str:
1372
+ """A URL as filter-urls compares it: scheme and host in lower case, no `#fragment`."""
1373
+ return parts._replace(scheme=parts.scheme.lower(), netloc=parts.netloc.lower(), fragment="").geturl()
1374
+
1375
+
1376
+ def filter_urls(root: str, candidates: List[Tuple[str, Optional[str]]]) -> Dict[str, Any]:
1377
+ """Cut subpage candidates to the root page's site and drop non-API pages."""
1378
+ root_parts = urllib.parse.urlsplit(root.strip())
1379
+ if root_parts.scheme.lower() not in ("http", "https") or not root_parts.hostname:
1380
+ raise ValueError(f"root is not an http or https URL: {root!r}")
1381
+ domain = registrable_domain(root_parts.hostname)
1382
+ root_key = _url_key(root_parts).rstrip("/")
1383
+ stats = {"input": len(candidates), "kept": 0, "other_domain": 0, "excluded_path": 0,
1384
+ "not_http": 0, "duplicate": 0, "root": 0, "sitemap": 0}
1385
+ kept: List[Dict[str, Any]] = []
1386
+ sitemaps: List[str] = []
1387
+ seen: set = set()
1388
+ for url, title in candidates:
1389
+ url = url.strip()
1390
+ if url.startswith("/"):
1391
+ # A crawl may list a page by its path: read it against the root.
1392
+ url = urllib.parse.urljoin(root.strip(), url)
1393
+ try:
1394
+ parts = urllib.parse.urlsplit(url)
1395
+ host = parts.hostname
1396
+ except ValueError:
1397
+ parts, host = None, None
1398
+ if parts is None or parts.scheme.lower() not in ("http", "https") or not host:
1399
+ stats["not_http"] += 1
1400
+ continue
1401
+ key = _url_key(parts)
1402
+ if key.rstrip("/") == root_key:
1403
+ stats["root"] += 1
1404
+ continue
1405
+ if key in seen:
1406
+ stats["duplicate"] += 1
1407
+ continue
1408
+ seen.add(key)
1409
+ if registrable_domain(host) != domain:
1410
+ stats["other_domain"] += 1
1411
+ continue
1412
+ if parts.path.lower().endswith((".xml", ".xml.gz")):
1413
+ # A sitemap index lists sitemaps, not pages.
1414
+ sitemaps.append(key)
1415
+ stats["sitemap"] += 1
1416
+ continue
1417
+ if _excluded_path(parts.path):
1418
+ stats["excluded_path"] += 1
1419
+ continue
1420
+ kept.append({"url": key, "title": title, "terms": _api_terms(parts.path, title or "")})
1421
+ stats["kept"] = len(kept)
1422
+ return {"root": root, "registrable_domain": domain, "kept": kept, "sitemaps": sitemaps, "stats": stats}
1423
+
1424
+
1425
+ def _cmd_filter_urls(argv: List[str]) -> int:
1426
+ parser = argparse.ArgumentParser(
1427
+ prog="skf-detect-docs.py filter-urls",
1428
+ description=(
1429
+ "Keep the subpage URLs on a documentation root page's own site "
1430
+ "(its registrable domain) and drop pages with no API content."
1431
+ ),
1432
+ epilog=_doc_section("filter-urls"),
1433
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1434
+ )
1435
+ parser.add_argument("--root", required=True, help="the root page's URL")
1436
+ parser.add_argument(
1437
+ "source",
1438
+ help=(
1439
+ "a sitemap.xml, a JSON array of URLs or {url, title} objects (or an "
1440
+ "object with a links or urls array), or one URL per line; - for stdin"
1441
+ ),
1442
+ )
1443
+ args = parser.parse_args(argv)
1444
+
1445
+ text, error = _read_source(args.source)
1446
+ if text is None:
1447
+ json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
1448
+ sys.stderr.write("\n")
1449
+ return 2
1450
+ label = "<stdin>" if args.source == "-" else args.source
1451
+ try:
1452
+ result = filter_urls(args.root, _candidate_urls(text, label))
1453
+ except ValueError as exc:
1454
+ json.dump({"error": str(exc), "code": "INVALID_INPUT"}, sys.stderr)
1455
+ sys.stderr.write("\n")
1456
+ return 2
1457
+ json.dump(result, sys.stdout, separators=(",", ":"))
1458
+ sys.stdout.write("\n")
1459
+ return 0
1460
+
1461
+
778
1462
  # ---------------------------------------------------------------------------
779
1463
  # CLI entry point
780
1464
  # ---------------------------------------------------------------------------
@@ -805,8 +1489,16 @@ def main() -> int:
805
1489
  # invocation `--repo-url <url> ...` is unchanged.
806
1490
  if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
807
1491
  return _cmd_compare_hashes(sys.argv[2:])
1492
+ if len(sys.argv) > 1 and sys.argv[1] == "refresh-hashes":
1493
+ return _cmd_refresh_hashes(sys.argv[2:])
808
1494
  if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
809
1495
  return _cmd_hash_urls(sys.argv[2:])
1496
+ if len(sys.argv) > 1 and sys.argv[1] == "readme-entry":
1497
+ return _cmd_readme_entry(sys.argv[2:])
1498
+ if len(sys.argv) > 1 and sys.argv[1] == "page-metrics":
1499
+ return _cmd_page_metrics(sys.argv[2:])
1500
+ if len(sys.argv) > 1 and sys.argv[1] == "filter-urls":
1501
+ return _cmd_filter_urls(sys.argv[2:])
810
1502
 
811
1503
  parser = argparse.ArgumentParser(
812
1504
  description="Detect documentation URLs for a GitHub repository.",