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
@@ -0,0 +1,772 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Classify Changed Files: update-skill's file-level changes (Category A) in one call.
6
+
7
+ skf-update-skill's detect-changes step sorts the source's files into
8
+ MODIFIED, ADDED and DELETED against the provenance map and the brief's
9
+ scope, before Category B compares the exports of the MODIFIED files. Done
10
+ by hand, that is glob matching (`**`, an exclude that overrides an include)
11
+ and set arithmetic in the prompt, where one missed match silently drops a
12
+ new in-scope file and its exports are never extracted. This helper does it
13
+ in one call, with the tested `glob_match` of
14
+ skf-resolve-authoritative-files.py.
15
+
16
+ Subcommand:
17
+ classify --source-root <path> [--provenance-map <provenance-map.json>]
18
+ [--brief <skill-brief.yaml>] [--language <language>]...
19
+ [--tree-status <status>] [--diff-status <status>]
20
+ [--changed-files <changed-files.json>] [--exclude <path>]...
21
+ [--lists-dir <dir>]
22
+
23
+ --tree-status, --diff-status and --changed-files take the values the
24
+ update-skill init step bound from skf-source-tree.py (`status`,
25
+ `diff_status`, `changed_files`); an empty, `null` or `none` value counts
26
+ as not given. --exclude (repeatable) names a path to leave out entirely:
27
+ the change-detection excludes, such as a document the authoritative-files
28
+ mirror just promoted. Without --brief (a skill built without one, such
29
+ as a quick skill) the scope is empty. --language (repeatable) is the
30
+ skill's language, metadata.json `language`. Without --provenance-map
31
+ (update-skill's degraded mode) the mode is `full`.
32
+
33
+ --lists-dir writes two JSON arrays of paths into that folder for the
34
+ steps that read the files next: modified-files.json (category_a's
35
+ modified files, the ones whose exports are diffed) and extract-files.json
36
+ (the modified and added files and each moved file's new path, whose
37
+ current exports are extracted).
38
+
39
+ The files it sorts:
40
+ tracked a file a provenance-map `entries[].source_file` names
41
+ named a tracked file, or one a `file_entries[].source_file` names
42
+ (a script, an asset or a promoted document: those are
43
+ Category D's, never ADDED here)
44
+ in scope a `scope.include` glob matches it and no `scope.exclude` glob
45
+ does. With an empty `scope.include`, a file whose extension one
46
+ of the tracked files has, or --language's language uses, and
47
+ that no `scope.exclude` glob matches.
48
+ promoted a file a promoted glob matches: the path of a
49
+ `scope.amendments[]` entry whose latest action is `promoted`
50
+ with category `scope-expansion`, when no tracked file matches
51
+ that glob yet (the scope reconciliation promoted it, and its
52
+ files were never extracted)
53
+ Globs follow the brief's semantics: `**` spans any number of folders,
54
+ `*` and `?` never cross a `/`. Walking the source skips symbolic links
55
+ and generated or vendored folders (node_modules, dist, build, .git, ...),
56
+ and an unnamed file in one of them is never ADDED. An --exclude path is
57
+ in no list.
58
+
59
+ Modes, from the statuses:
60
+ diff --diff-status ok (or --changed-files alone): the changed-files.json
61
+ skf-source-tree.py wrote, git's list of the files changed since the
62
+ skill's commit, {"base", "target", "files": [{"status": "A"|"M"|"D",
63
+ "path"}]}. A checkout rewrites every timestamp, so only this list
64
+ says what changed.
65
+ MODIFIED a tracked file with an M or A row
66
+ DELETED a tracked file with a D row, or one the source lacks
67
+ ADDED an unnamed, in-scope file with an A or M row (a file
68
+ that exported nothing when the map was built can gain
69
+ an export), or a promoted file, changed or not
70
+ MOVED a DELETED file with a D row and an ADDED file with an A
71
+ row holding the same contents (one git blob at base and
72
+ at target, each matched once): listed in moved_files,
73
+ and in neither added nor deleted
74
+ tree-without-diff --tree-status ready or offline, the diff unavailable:
75
+ the skill's commit could not be read, so
76
+ MODIFIED every tracked file the source holds
77
+ DELETED a tracked file the source lacks
78
+ ADDED every unnamed, in-scope file
79
+ with a `file-diff-unavailable` warning.
80
+ local anything else (a local source, read as it stands): a walk that
81
+ compares each file's modification time with the provenance map's
82
+ time, the later of its `generated_at` and, when its top-level
83
+ `update_type` is `incremental` or `full` (an update that read the
84
+ source), its top-level `last_update`. A time with a zone is that
85
+ instant. A date alone (update-skill wrote `last_update` as a date
86
+ before v3.0.0) or a time without a zone is the earliest instant it
87
+ can name, 14 hours before its UTC reading (the day starts first at
88
+ UTC+14): a baseline too early only re-checks a few files, while
89
+ one too late would miss a file edited after the update.
90
+ MODIFIED a tracked file modified at or after that time
91
+ DELETED a tracked file the source lacks
92
+ ADDED an unnamed, in-scope file modified at or after that
93
+ time, or a promoted file
94
+ With no usable time every tracked file the source holds is
95
+ MODIFIED and every unnamed, in-scope file ADDED, with a
96
+ `no-baseline-time` warning. No MOVED: a deleted file's contents
97
+ are gone.
98
+ full no --provenance-map: with no map to compare against, every
99
+ in-scope file the walk finds is MODIFIED, and none is ADDED,
100
+ DELETED or MOVED. A run with nothing to scope the walk by (no
101
+ `scope.include` glob and no --language this helper knows) is an
102
+ input error rather than an empty list.
103
+
104
+ An unknown --language value adds an `unknown-language` warning.
105
+
106
+ Library use: the one brief-scope test for script and asset paths.
107
+ skf-new-file-diff.py (update-skill Category D, --brief),
108
+ skf-detect-scripts-assets.py detect --brief (create-skill) and
109
+ skf-compare-file-hashes.py compare --brief (audit-skill) load this file by
110
+ its path and call load_scope(brief_path, cited). It returns a Scope:
111
+ takes(path) is the in-scope test above (`_in_scope`), with the brief's
112
+ `scope.include` and `scope.exclude` normalized as classify normalizes them
113
+ and, for an empty `scope.include`, the extensions of the `cited` paths
114
+ (the provenance map's `entries[].source_file`) and of the brief's
115
+ `language`; wants(kind) is False for a `script` or `asset` whose
116
+ `scripts_intent` or `assets_intent` is `none` (an absent or free-text
117
+ intent detects). load_scope raises ValueError, `brief not found: <path>`
118
+ for a path that names no file and load_brief's message for a brief it
119
+ cannot read; each caller keeps its own exit code.
120
+
121
+ Output JSON (stdout):
122
+
123
+ {
124
+ "status": "ok",
125
+ "mode": "diff" | "tree-without-diff" | "local" | "full",
126
+ "category_a": {"modified": [...], "added": [...], "deleted": [...]},
127
+ "moved_files": [{"old_path": "...", "new_path": "..."}],
128
+ "summary": {"tracked": N, "modified": N, "added": N, "deleted": N,
129
+ "moved": N, "unchanged": N},
130
+ "baseline_time": "<YYYY-MM-DDTHH:MM:SSZ>" | null,
131
+ "baseline_source": "last_update" | "generated_at" | null,
132
+ "warnings": ["file-diff-unavailable: ..." | "no-baseline-time: ..." |
133
+ "moved-check-skipped: ..." | "unknown-language: ..."]
134
+ }
135
+
136
+ category_a is the slice skf-build-change-manifest.py build reads, and each
137
+ moved_files item has the shape of its category_c.renamed_files items.
138
+ Paths are relative to the source root, with forward slashes, sorted.
139
+ baseline_time is the instant local mode compared the files with (a date
140
+ alone already moved to its earliest instant), and baseline_source the key
141
+ it came from; both are null outside local mode. summary
142
+ counts the tracked files the run read (an --exclude path is not one), and
143
+ unchanged is those neither MODIFIED, DELETED nor moved.
144
+
145
+ Exit codes:
146
+ 0 classified
147
+ 1 input error: a missing source root, provenance map, brief or
148
+ changed-files file, one that cannot be parsed, --diff-status ok
149
+ without --changed-files, a full mode with nothing to scope the walk
150
+ by, or a --lists-dir it cannot write (message on stderr)
151
+ 2 usage error (argparse, usage on stderr)
152
+ """
153
+
154
+ from __future__ import annotations
155
+
156
+ import argparse
157
+ import importlib.util
158
+ import json
159
+ import os
160
+ import sys
161
+ from datetime import datetime, timedelta, timezone
162
+ from pathlib import Path, PurePosixPath
163
+
164
+ _TREE_STATUSES = ("ready", "offline")
165
+ # The update types that re-read the whole source (a normal update and a
166
+ # rebuild without a provenance map); a gap-driven repair reads no changes.
167
+ _SOURCE_READING_UPDATES = ("incremental", "full")
168
+ # The easternmost zone (UTC+14): a day there starts 14 hours before UTC's.
169
+ _EARLIEST_ZONE_OFFSET = timedelta(hours=14)
170
+ _GIT_TIMEOUT_SEC = 60.0
171
+ # --language (metadata.json `language`) -> the source file extensions a skill
172
+ # in that language reads: the recipe runner's language families
173
+ # (skf-extract-public-api.py EXTENSION_LANGUAGES) and the languages it has
174
+ # no recipe for, which are read by eye.
175
+ _JS_EXTENSIONS = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue")
176
+ _C_EXTENSIONS = (".c", ".h")
177
+ _CPP_EXTENSIONS = (".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h")
178
+ LANGUAGE_EXTENSIONS = {
179
+ **dict.fromkeys(("javascript", "js", "jsx", "typescript", "ts", "tsx", "vue"), _JS_EXTENSIONS),
180
+ "python": (".py", ".pyi"),
181
+ "rust": (".rs",),
182
+ "go": (".go",),
183
+ "golang": (".go",),
184
+ "java": (".java",),
185
+ "kotlin": (".kt", ".kts"),
186
+ "swift": (".swift",),
187
+ "csharp": (".cs",),
188
+ "c#": (".cs",),
189
+ "php": (".php",),
190
+ "ruby": (".rb",),
191
+ "c": _C_EXTENSIONS,
192
+ "cpp": _CPP_EXTENSIONS,
193
+ "c++": _CPP_EXTENSIONS,
194
+ }
195
+ _RESOLVER = None
196
+ _SOURCE_TREE = None
197
+
198
+
199
+ def _sibling(name: str, stem: str):
200
+ path = Path(__file__).resolve().parent / f"{stem}.py"
201
+ spec = importlib.util.spec_from_file_location(name, path)
202
+ if spec is None or spec.loader is None:
203
+ raise ImportError(f"cannot load {path}")
204
+ module = importlib.util.module_from_spec(spec)
205
+ spec.loader.exec_module(module)
206
+ return module
207
+
208
+
209
+ def _resolver():
210
+ """skf-resolve-authoritative-files.py from this folder, loaded once: the
211
+ brief is read and matched with its load_brief, glob_match, scope_match,
212
+ normalize_rel_path and EXCLUDED_DIR_NAMES."""
213
+ global _RESOLVER
214
+ if _RESOLVER is None:
215
+ _RESOLVER = _sibling("skf_resolve_authoritative_files", "skf-resolve-authoritative-files")
216
+ return _RESOLVER
217
+
218
+
219
+ def _source_tree():
220
+ """skf-source-tree.py from this folder, loaded once: git runs through
221
+ its runner (_run stops a call at the time limit; _resolve_outside_cwd
222
+ never takes a git planted in the current folder)."""
223
+ global _SOURCE_TREE
224
+ if _SOURCE_TREE is None:
225
+ _SOURCE_TREE = _sibling("skf_source_tree", "skf-source-tree")
226
+ return _SOURCE_TREE
227
+
228
+
229
+ def _given(value: str | None) -> str:
230
+ """value stripped; "" for an empty, `null` or `none` value."""
231
+ value = (value or "").strip()
232
+ return "" if value.lower() in ("null", "none") else value
233
+
234
+
235
+ def language_extensions(languages: list[str]) -> tuple[set[str], list[str]]:
236
+ """(the extensions the --language values use, the values none is known for).
237
+
238
+ An empty or `null` value is no language.
239
+
240
+ >>> sorted(language_extensions(["Python", "null"])[0]), language_extensions(["cobol"])[1]
241
+ (['.py', '.pyi'], ['cobol'])
242
+ """
243
+ extensions: set[str] = set()
244
+ unknown = []
245
+ for value in languages:
246
+ if not _given(value):
247
+ continue
248
+ known = LANGUAGE_EXTENSIONS.get(_given(value).lower())
249
+ if known is None:
250
+ unknown.append(value)
251
+ else:
252
+ extensions.update(known)
253
+ return extensions, unknown
254
+
255
+
256
+ # --------------------------------------------------------------------------
257
+ # Inputs
258
+ # --------------------------------------------------------------------------
259
+
260
+
261
+ def _read_json(path: Path, what: str) -> object:
262
+ try:
263
+ return json.loads(path.read_text(encoding="utf-8"))
264
+ except OSError as exc:
265
+ raise ValueError(f"cannot read {what} at {path}: {exc}") from exc
266
+ except ValueError as exc:
267
+ raise ValueError(f"{what} at {path} is not valid JSON: {exc}") from exc
268
+
269
+
270
+ def _source_files(provenance: dict, key: str) -> set[str]:
271
+ norm = _resolver().normalize_rel_path
272
+ rows = provenance.get(key)
273
+ if not isinstance(rows, list):
274
+ return set()
275
+ return {
276
+ norm(row["source_file"])
277
+ for row in rows
278
+ if isinstance(row, dict) and isinstance(row.get("source_file"), str) and row["source_file"].strip()
279
+ }
280
+
281
+
282
+ def _parse_time(value: object) -> datetime | None:
283
+ """An ISO-8601 date or date-time as a UTC instant, or None.
284
+
285
+ A time with a zone is that instant. A date alone, or a time without a
286
+ zone, was written in some zone as far east as UTC+14, so it is read as
287
+ the earliest instant it can name: its UTC reading less 14 hours. A
288
+ baseline too early only re-checks a few files; one too late would miss
289
+ a file edited after the update.
290
+ """
291
+ if not isinstance(value, str) or not value.strip():
292
+ return None
293
+ try:
294
+ moment = datetime.fromisoformat(value.strip())
295
+ if moment.tzinfo is None:
296
+ return moment.replace(tzinfo=timezone.utc) - _EARLIEST_ZONE_OFFSET
297
+ return moment.astimezone(timezone.utc)
298
+ except (ValueError, OverflowError):
299
+ return None
300
+
301
+
302
+ def baseline_time(provenance: dict) -> tuple[datetime | None, str | None]:
303
+ """(time, source) a local source's files are compared with: the later of
304
+ generated_at and a source-reading update's last_update."""
305
+ found = []
306
+ generated = _parse_time(provenance.get("generated_at"))
307
+ if generated is not None:
308
+ found.append((generated, "generated_at"))
309
+ if provenance.get("update_type") in _SOURCE_READING_UPDATES:
310
+ updated = _parse_time(provenance.get("last_update"))
311
+ if updated is not None:
312
+ found.append((updated, "last_update"))
313
+ if not found:
314
+ return None, None
315
+ return max(found, key=lambda item: item[0])
316
+
317
+
318
+ def promoted_globs(brief: dict, tracked: set[str]) -> list[str]:
319
+ """The scope-expansion globs the latest scope-expansion amendment promoted
320
+ and no tracked file matches yet. An entry of another category on the same
321
+ path (an auth-doc or a demo-and-registry decision) never stands in for it."""
322
+ resolver = _resolver()
323
+ scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
324
+ amendments = scope.get("amendments") if isinstance(scope.get("amendments"), list) else []
325
+ latest: dict[str, dict] = {}
326
+ for amend in amendments:
327
+ if (isinstance(amend, dict) and amend.get("category") == "scope-expansion"
328
+ and isinstance(amend.get("path"), str) and amend["path"].strip()):
329
+ latest[resolver.normalize_rel_path(amend["path"])] = amend
330
+ return sorted(
331
+ glob
332
+ for glob, amend in latest.items()
333
+ if amend.get("action") == "promoted"
334
+ and not any(resolver.glob_match(path, glob) for path in tracked)
335
+ )
336
+
337
+
338
+ # --------------------------------------------------------------------------
339
+ # Walk and scope
340
+ # --------------------------------------------------------------------------
341
+
342
+
343
+ def _vendored(rel: str) -> bool:
344
+ return any(part in _resolver().EXCLUDED_DIR_NAMES for part in rel.split("/")[:-1])
345
+
346
+
347
+ def walk_source(root: Path) -> list[tuple[str, Path]]:
348
+ """(relative path, path) of every file under root, sorted: no symbolic
349
+ link, and no generated or vendored folder."""
350
+ excluded = _resolver().EXCLUDED_DIR_NAMES
351
+ found = []
352
+ for dirpath, dirnames, filenames in os.walk(root):
353
+ here = Path(dirpath)
354
+ dirnames[:] = [d for d in dirnames if d not in excluded and not (here / d).is_symlink()]
355
+ for name in filenames:
356
+ path = here / name
357
+ if path.is_symlink() or not path.is_file():
358
+ continue
359
+ found.append((path.relative_to(root).as_posix(), path))
360
+ return sorted(found)
361
+
362
+
363
+ def _in_scope(rel: str, includes: list[str], excludes: list[str], extensions: set[str]) -> bool:
364
+ resolver = _resolver()
365
+ if includes:
366
+ return resolver.scope_match(rel, includes, excludes)[0]
367
+ if any(resolver.glob_match(rel, pattern) for pattern in excludes):
368
+ return False
369
+ return PurePosixPath(rel).suffix in extensions
370
+
371
+
372
+ # --------------------------------------------------------------------------
373
+ # The brief's scope for script and asset paths (Library use)
374
+ # --------------------------------------------------------------------------
375
+
376
+
377
+ # kind -> the brief field that holds its intent
378
+ INTENT_FIELDS = {"script": "scripts_intent", "asset": "assets_intent"}
379
+
380
+
381
+ class Scope:
382
+ """A skill brief's scope and script and asset intents, by this helper's in-scope test."""
383
+
384
+ def __init__(self, includes: list[str], excludes: list[str], extensions: set[str],
385
+ intents: dict[str, str]):
386
+ self.includes = includes
387
+ self.excludes = excludes
388
+ self.extensions = extensions
389
+ self.intents = intents
390
+
391
+ def takes(self, rel: str) -> bool:
392
+ """True when the brief's scope takes `rel`, a path relative to the source root."""
393
+ return _in_scope(_resolver().normalize_rel_path(rel), self.includes, self.excludes, self.extensions)
394
+
395
+ def wants(self, kind: str) -> bool:
396
+ """False when the brief's intent for `kind` (`script` or `asset`) is `none`."""
397
+ return self.intents.get(kind) != "none"
398
+
399
+
400
+ def _intent(value: object) -> str:
401
+ """`none` for a `none` intent; `detect` for an absent or free-text one."""
402
+ if isinstance(value, str) and value.strip().lower() == "none":
403
+ return "none"
404
+ return "detect"
405
+
406
+
407
+ def load_scope(brief_path: Path, cited: set[str] | frozenset = frozenset()) -> Scope:
408
+ """The brief's Scope: its include and exclude patterns, normalized as
409
+ classify normalizes them, the extensions an empty include falls back to
410
+ (the cited paths' and the brief language's) and the script and asset
411
+ intents. Raises ValueError for a brief path that names no file or a
412
+ brief it cannot read."""
413
+ try:
414
+ found = brief_path.is_file()
415
+ except OSError as exc: # a folder on the way that cannot be searched
416
+ raise ValueError(f"cannot read the skill brief {brief_path}: {exc}") from exc
417
+ if not found:
418
+ raise ValueError(f"brief not found: {brief_path}")
419
+ resolver = _resolver()
420
+ brief = resolver.load_brief(brief_path)
421
+ includes, excludes, _ = resolver.extract_scope(brief)
422
+ norm = resolver.normalize_rel_path
423
+ includes = [norm(p) for p in includes]
424
+ excludes = [norm(p) for p in excludes]
425
+ extensions: set[str] = set()
426
+ if not includes:
427
+ extensions = {PurePosixPath(p).suffix for p in cited if PurePosixPath(p).suffix}
428
+ language = brief.get("language")
429
+ if isinstance(language, str) and language.strip():
430
+ extensions |= language_extensions([language])[0]
431
+ intents = {kind: _intent(brief.get(field)) for kind, field in INTENT_FIELDS.items()}
432
+ return Scope(includes, excludes, extensions, intents)
433
+
434
+
435
+ # --------------------------------------------------------------------------
436
+ # Same-content moves (diff mode)
437
+ # --------------------------------------------------------------------------
438
+
439
+
440
+ def _blobs(tree: Path, commit: str, paths: set[str]) -> dict[str, str]:
441
+ """{path: blob id} at commit for the given paths. Raises RuntimeError
442
+ when git cannot list the commit."""
443
+ try:
444
+ runner = _source_tree()
445
+ except (ImportError, OSError) as exc:
446
+ raise RuntimeError(f"cannot load skf-source-tree.py: {exc}") from exc
447
+ exe = runner._resolve_outside_cwd("git")
448
+ if exe is None:
449
+ raise RuntimeError("git is not installed")
450
+ argv = [exe, "-c", f"core.hooksPath={os.devnull}", "-C", str(tree), "ls-tree", "-r", "-z", commit]
451
+ try:
452
+ rc, out, err, _killed = runner._run(argv, _GIT_TIMEOUT_SEC)
453
+ except (OSError, ValueError) as exc:
454
+ raise RuntimeError(f"git could not run: {exc}") from exc
455
+ if rc is None:
456
+ raise RuntimeError(f"git ls-tree timed out after {_GIT_TIMEOUT_SEC:g} seconds")
457
+ if rc != 0:
458
+ raise RuntimeError(runner._last_error(err.decode("utf-8", errors="replace")))
459
+ blobs = {}
460
+ for record in out.split(b"\0"):
461
+ meta, _, raw_path = record.partition(b"\t")
462
+ fields = meta.split()
463
+ if len(fields) != 3 or fields[1] != b"blob":
464
+ continue
465
+ path = raw_path.decode("utf-8", errors="surrogateescape")
466
+ if path in paths:
467
+ blobs[path] = fields[2].decode("ascii", errors="replace")
468
+ return blobs
469
+
470
+
471
+ def same_content_moves(
472
+ tree: Path, base: str, target: str, deleted: set[str], added: set[str]
473
+ ) -> list[dict]:
474
+ """{old_path, new_path} for each deleted file whose blob at base is the
475
+ blob of exactly one added file at target, and no other deleted file's.
476
+ Raises RuntimeError when git cannot list either commit."""
477
+ if not deleted or not added:
478
+ return []
479
+ by_blob: dict[str, tuple[list[str], list[str]]] = {}
480
+ for path, blob in _blobs(tree, base, deleted).items():
481
+ by_blob.setdefault(blob, ([], []))[0].append(path)
482
+ for path, blob in _blobs(tree, target, added).items():
483
+ by_blob.setdefault(blob, ([], []))[1].append(path)
484
+ return sorted(
485
+ ({"old_path": old[0], "new_path": new[0]} for old, new in by_blob.values() if len(old) == 1 and len(new) == 1),
486
+ key=lambda move: move["new_path"],
487
+ )
488
+
489
+
490
+ # --------------------------------------------------------------------------
491
+ # Classify
492
+ # --------------------------------------------------------------------------
493
+
494
+
495
+ def _changed_rows(data: object, path: Path) -> tuple[dict[str, str], str, str]:
496
+ """({path: status}, base, target) of a changed-files.json."""
497
+ norm = _resolver().normalize_rel_path
498
+ if not isinstance(data, dict) or not isinstance(data.get("files"), list):
499
+ raise ValueError(f"changed-files file at {path} has no files[] list")
500
+ rows = {}
501
+ for row in data["files"]:
502
+ if isinstance(row, dict) and row.get("status") in ("A", "M", "D") and isinstance(row.get("path"), str):
503
+ rows[norm(row["path"])] = row["status"]
504
+ base, target = data.get("base"), data.get("target")
505
+ return rows, base if isinstance(base, str) else "", target if isinstance(target, str) else ""
506
+
507
+
508
+ def classify(
509
+ source_root: Path,
510
+ provenance: dict | None,
511
+ brief: dict,
512
+ *,
513
+ tree_status: str = "",
514
+ diff_status: str = "",
515
+ changed: object = None,
516
+ changed_path: Path | None = None,
517
+ excludes: list[str] | None = None,
518
+ languages: list[str] | None = None,
519
+ ) -> dict:
520
+ """Run Category A. See the module docstring for the output shape.
521
+
522
+ provenance is None in full mode, and brief {} for a skill without one.
523
+ changed is the parsed changed-files.json in diff mode (None otherwise).
524
+ Raises ValueError for a full mode with nothing to scope the walk by.
525
+ """
526
+ resolver = _resolver()
527
+ norm = resolver.normalize_rel_path
528
+ left_out = {norm(p) for p in excludes or [] if isinstance(p, str) and p.strip()}
529
+ tracked = _source_files(provenance or {}, "entries") - left_out
530
+ named = tracked | _source_files(provenance or {}, "file_entries")
531
+ includes, scope_excludes, _ = resolver.extract_scope(brief)
532
+ includes = [norm(p) for p in includes]
533
+ scope_excludes = [norm(p) for p in scope_excludes]
534
+ spoken, unknown_languages = language_extensions(languages or [])
535
+ extensions = {PurePosixPath(p).suffix for p in tracked if PurePosixPath(p).suffix} | spoken
536
+ promoted = promoted_globs(brief, tracked)
537
+
538
+ def candidate(rel: str) -> bool:
539
+ """An unnamed, in-scope file: ADDED when it changed, MODIFIED in full mode."""
540
+ return (
541
+ rel not in named
542
+ and rel not in left_out
543
+ and not _vendored(rel)
544
+ and _in_scope(rel, includes, scope_excludes, extensions)
545
+ )
546
+
547
+ def is_promoted(rel: str) -> bool:
548
+ return any(resolver.glob_match(rel, glob) for glob in promoted)
549
+
550
+ def present(rel: str) -> bool:
551
+ return (source_root / rel).is_file()
552
+
553
+ warnings: list[str] = [
554
+ f"unknown-language: no source file extension is known for {value!r}" for value in unknown_languages
555
+ ]
556
+ moved: list[dict] = []
557
+ modified: set[str] = set()
558
+ added: set[str] = set()
559
+ deleted: set[str] = set()
560
+ baseline, baseline_source = None, None
561
+
562
+ if provenance is None:
563
+ mode = "full"
564
+ if not includes and not extensions:
565
+ raise ValueError(
566
+ "nothing scopes the walk without a provenance map: give a --brief whose scope.include "
567
+ "names the source files, or the skill's --language"
568
+ )
569
+ modified.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
570
+ elif diff_status == "ok" or (not diff_status and changed is not None):
571
+ mode = "diff"
572
+ rows, base, target = _changed_rows(changed, changed_path or Path("changed-files.json"))
573
+ for rel in tracked:
574
+ status = rows.get(rel)
575
+ if status == "D" or not present(rel):
576
+ deleted.add(rel)
577
+ elif status in ("A", "M"):
578
+ modified.add(rel)
579
+ for rel, status in rows.items():
580
+ if status in ("A", "M") and candidate(rel) and present(rel):
581
+ added.add(rel)
582
+ if promoted:
583
+ added.update(rel for rel, _ in walk_source(source_root) if candidate(rel) and is_promoted(rel))
584
+ gone = {rel for rel in deleted if rows.get(rel) == "D"}
585
+ new = {rel for rel in added if rows.get(rel) == "A"}
586
+ if gone and new:
587
+ try:
588
+ if not (base and target):
589
+ raise RuntimeError("the changed-files list names no base or target commit")
590
+ moved = same_content_moves(source_root, base, target, gone, new)
591
+ except RuntimeError as exc:
592
+ warnings.append(f"moved-check-skipped: {exc}")
593
+ for move in moved:
594
+ deleted.discard(move["old_path"])
595
+ added.discard(move["new_path"])
596
+ elif tree_status in _TREE_STATUSES:
597
+ mode = "tree-without-diff"
598
+ for rel in tracked:
599
+ (modified if present(rel) else deleted).add(rel)
600
+ added.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
601
+ commit = provenance.get("source_commit")
602
+ commit = commit.strip() if isinstance(commit, str) and commit.strip() else "the skill's commit"
603
+ warnings.append(
604
+ f"file-diff-unavailable: {commit} could not be read, so every tracked file was re-checked"
605
+ )
606
+ else:
607
+ mode = "local"
608
+ baseline, baseline_source = baseline_time(provenance)
609
+ since = baseline.timestamp() if baseline is not None else None
610
+
611
+ def touched(path: Path) -> bool:
612
+ if since is None:
613
+ return True
614
+ try:
615
+ return path.stat().st_mtime >= since
616
+ except OSError:
617
+ return True
618
+
619
+ for rel in tracked:
620
+ path = source_root / rel
621
+ if not path.is_file():
622
+ deleted.add(rel)
623
+ elif touched(path):
624
+ modified.add(rel)
625
+ added.update(
626
+ rel for rel, path in walk_source(source_root)
627
+ if candidate(rel) and (touched(path) or is_promoted(rel))
628
+ )
629
+ if baseline is None:
630
+ warnings.append(
631
+ "no-baseline-time: the provenance map records no usable generated_at or "
632
+ "last_update, so every tracked file was re-checked and every in-scope file "
633
+ "it does not name was listed as added"
634
+ )
635
+
636
+ unchanged = len(tracked - modified - deleted - {m["old_path"] for m in moved})
637
+ return {
638
+ "status": "ok",
639
+ "mode": mode,
640
+ "category_a": {
641
+ "modified": sorted(modified),
642
+ "added": sorted(added),
643
+ "deleted": sorted(deleted),
644
+ },
645
+ "moved_files": moved,
646
+ "summary": {
647
+ "tracked": len(tracked),
648
+ "modified": len(modified),
649
+ "added": len(added),
650
+ "deleted": len(deleted),
651
+ "moved": len(moved),
652
+ "unchanged": unchanged,
653
+ },
654
+ "baseline_time": baseline.strftime("%Y-%m-%dT%H:%M:%SZ") if baseline is not None else None,
655
+ "baseline_source": baseline_source,
656
+ "warnings": warnings,
657
+ }
658
+
659
+
660
+ # --------------------------------------------------------------------------
661
+ # CLI
662
+ # --------------------------------------------------------------------------
663
+
664
+
665
+ def write_lists(result: dict, folder: Path) -> None:
666
+ """--lists-dir: modified-files.json and extract-files.json. Raises
667
+ ValueError when a list cannot be written."""
668
+ category_a = result["category_a"]
669
+ extract = set(category_a["modified"]) | set(category_a["added"])
670
+ extract.update(move["new_path"] for move in result["moved_files"])
671
+ for name, paths in (("modified-files.json", category_a["modified"]), ("extract-files.json", sorted(extract))):
672
+ try:
673
+ (folder / name).write_text(json.dumps(paths, indent=2) + "\n", encoding="utf-8")
674
+ except OSError as exc:
675
+ raise ValueError(f"cannot write {name} in {folder}: {exc}") from exc
676
+
677
+
678
+ def _cmd_classify(args: argparse.Namespace) -> int:
679
+ source_root = Path(args.source_root)
680
+ if not source_root.is_dir():
681
+ print(f"error: source-root not a directory: {source_root}", file=sys.stderr)
682
+ return 1
683
+ for label, value in (("provenance map", args.provenance_map), ("brief", args.brief)):
684
+ if value is not None and not Path(value).is_file():
685
+ print(f"error: {label} not found: {value}", file=sys.stderr)
686
+ return 1
687
+ tree_status = _given(args.tree_status).lower()
688
+ diff_status = _given(args.diff_status).lower()
689
+ changed_files = _given(args.changed_files)
690
+ if diff_status == "ok" and not changed_files and args.provenance_map is not None:
691
+ print("error: --diff-status ok needs --changed-files", file=sys.stderr)
692
+ return 1
693
+ try:
694
+ provenance = None
695
+ if args.provenance_map is not None:
696
+ provenance = _read_json(Path(args.provenance_map), "provenance map")
697
+ if not isinstance(provenance, dict):
698
+ raise ValueError(f"provenance map at {args.provenance_map} must be a JSON object")
699
+ brief = _resolver().load_brief(Path(args.brief)) if args.brief is not None else {}
700
+ changed = None
701
+ if provenance is not None and changed_files and diff_status in ("ok", ""):
702
+ changed = _read_json(Path(changed_files), "changed-files list")
703
+ result = classify(
704
+ source_root,
705
+ provenance,
706
+ brief,
707
+ tree_status=tree_status,
708
+ diff_status=diff_status,
709
+ changed=changed,
710
+ changed_path=Path(changed_files) if changed_files else None,
711
+ excludes=args.exclude or [],
712
+ languages=args.language or [],
713
+ )
714
+ if args.lists_dir is not None:
715
+ write_lists(result, Path(args.lists_dir))
716
+ except ValueError as exc:
717
+ print(f"error: {exc}", file=sys.stderr)
718
+ return 1
719
+ print(json.dumps(result, indent=2))
720
+ return 0
721
+
722
+
723
+ def _build_parser() -> argparse.ArgumentParser:
724
+ # --help (and classify --help) print the module docstring: the modes,
725
+ # their MODIFIED, ADDED and DELETED rules and the output a step cites.
726
+ parser = argparse.ArgumentParser(
727
+ prog="skf-classify-changed-files",
728
+ description=__doc__,
729
+ formatter_class=argparse.RawDescriptionHelpFormatter,
730
+ )
731
+ sub = parser.add_subparsers(dest="cmd", required=True)
732
+ p = sub.add_parser(
733
+ "classify",
734
+ help="classify the file-level changes",
735
+ description=__doc__,
736
+ formatter_class=argparse.RawDescriptionHelpFormatter,
737
+ )
738
+ p.add_argument("--source-root", required=True, help="path to the source tree the run reads")
739
+ p.add_argument("--provenance-map", help="path to provenance-map.json (none: full mode)")
740
+ p.add_argument("--brief", help="path to skill-brief.yaml (none: an empty scope)")
741
+ p.add_argument(
742
+ "--language",
743
+ action="append",
744
+ metavar="LANG",
745
+ help="the skill's language, whose extensions an empty scope.include takes (repeatable)",
746
+ )
747
+ p.add_argument("--tree-status", help="skf-source-tree.py status (ready, offline, ...)")
748
+ p.add_argument("--diff-status", help="skf-source-tree.py diff_status (ok or unavailable)")
749
+ p.add_argument("--changed-files", help="path to skf-source-tree.py's changed-files.json")
750
+ p.add_argument(
751
+ "--exclude",
752
+ action="append",
753
+ metavar="PATH",
754
+ help="a path to leave out entirely (repeatable)",
755
+ )
756
+ p.add_argument(
757
+ "--lists-dir",
758
+ metavar="DIR",
759
+ help="write modified-files.json and extract-files.json into this folder",
760
+ )
761
+ p.set_defaults(func=_cmd_classify)
762
+ return parser
763
+
764
+
765
+ def main(argv: list[str] | None = None) -> int:
766
+ parser = _build_parser()
767
+ args = parser.parse_args(argv)
768
+ return args.func(args)
769
+
770
+
771
+ if __name__ == "__main__":
772
+ sys.exit(main())