bmad-module-skill-forge 1.4.1 → 1.5.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +0 -8
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/STABILITY.md +1 -1
  5. package/docs/architecture.md +2 -2
  6. package/docs/examples.md +1 -1
  7. package/docs/workflows.md +5 -5
  8. package/package.json +2 -2
  9. package/src/README.md +1 -1
  10. package/src/knowledge/ccc-bridge.md +12 -12
  11. package/src/knowledge/qmd-registry.md +5 -5
  12. package/src/shared/health-check.md +5 -5
  13. package/src/shared/references/description-guard-protocol.md +100 -0
  14. package/src/shared/references/output-contract-schema.md +1 -1
  15. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
  16. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
  17. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
  18. package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
  19. package/src/shared/scripts/skf-build-change-manifest.py +420 -0
  20. package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
  21. package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
  22. package/src/shared/scripts/skf-description-guard.py +359 -0
  23. package/src/shared/scripts/skf-detect-language.py +2 -2
  24. package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
  25. package/src/shared/scripts/skf-detect-tools.py +97 -8
  26. package/src/shared/scripts/skf-detect-workspaces.py +1 -1
  27. package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
  29. package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
  30. package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
  31. package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
  32. package/src/shared/scripts/skf-hash-content.py +284 -0
  33. package/src/shared/scripts/skf-load-provenance.py +295 -0
  34. package/src/shared/scripts/skf-merge-ccc-exclusions.py +34 -4
  35. package/src/shared/scripts/skf-pair-intersect.py +250 -0
  36. package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
  37. package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
  38. package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
  39. package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
  40. package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
  41. package/src/shared/scripts/skf-scan-manifests.py +738 -0
  42. package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
  43. package/src/shared/scripts/skf-update-active-symlink.py +315 -0
  44. package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
  45. package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
  46. package/src/shared/scripts/skf-write-skill-brief.py +61 -5
  47. package/src/skf-analyze-source/SKILL.md +69 -16
  48. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  49. package/src/skf-analyze-source/customize.toml +55 -0
  50. package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
  51. package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
  52. package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  53. package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
  54. package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
  55. package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
  56. package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
  57. package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
  58. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
  59. package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
  60. package/src/skf-audit-skill/SKILL.md +66 -15
  61. package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
  62. package/src/skf-audit-skill/customize.toml +49 -0
  63. package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  64. package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +57 -19
  65. package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
  66. package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
  67. package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
  68. package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
  69. package/src/skf-audit-skill/references/severity-rules.md +2 -0
  70. package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -17
  71. package/src/skf-brief-skill/SKILL.md +48 -18
  72. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  73. package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
  74. package/src/skf-brief-skill/customize.toml +44 -0
  75. package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
  76. package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
  77. package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
  78. package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
  79. package/src/skf-brief-skill/references/headless-args.md +9 -9
  80. package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
  81. package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
  82. package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
  83. package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
  84. package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
  85. package/src/skf-brief-skill/references/version-resolution.md +2 -2
  86. package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
  87. package/src/skf-create-skill/SKILL.md +25 -19
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
  89. package/src/skf-create-skill/assets/skill-sections.md +5 -5
  90. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
  91. package/src/skf-create-skill/customize.toml +35 -0
  92. package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
  93. package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
  94. package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
  95. package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
  96. package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
  97. package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
  98. package/src/skf-create-skill/references/extraction-patterns.md +8 -8
  99. package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
  100. package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
  101. package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
  102. package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
  103. package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
  104. package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
  105. package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
  106. package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
  107. package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
  108. package/src/skf-create-stack-skill/SKILL.md +76 -17
  109. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
  110. package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
  111. package/src/skf-create-stack-skill/customize.toml +46 -0
  112. package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
  113. package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
  114. package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
  115. package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
  116. package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
  117. package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
  118. package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
  119. package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
  120. package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
  121. package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
  122. package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
  123. package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
  124. package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
  125. package/src/skf-drop-skill/SKILL.md +84 -12
  126. package/src/skf-drop-skill/customize.toml +57 -0
  127. package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
  128. package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
  129. package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  130. package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
  131. package/src/skf-export-skill/SKILL.md +85 -16
  132. package/src/skf-export-skill/assets/managed-section-format.md +3 -3
  133. package/src/skf-export-skill/assets/snippet-format.md +2 -2
  134. package/src/skf-export-skill/customize.toml +49 -0
  135. package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
  136. package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  137. package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
  138. package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
  139. package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
  140. package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
  141. package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
  142. package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
  143. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
  144. package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +20 -8
  145. package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
  146. package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
  147. package/src/skf-quick-skill/SKILL.md +63 -123
  148. package/src/skf-quick-skill/customize.toml +44 -0
  149. package/src/skf-quick-skill/references/batch-mode.md +102 -0
  150. package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
  151. package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
  152. package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
  153. package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
  154. package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
  155. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  156. package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
  157. package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
  158. package/src/skf-refine-architecture/SKILL.md +86 -16
  159. package/src/skf-refine-architecture/customize.toml +49 -0
  160. package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
  161. package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
  162. package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  163. package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
  164. package/src/skf-refine-architecture/references/init.md +144 -0
  165. package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
  166. package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
  167. package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
  168. package/src/skf-rename-skill/SKILL.md +82 -12
  169. package/src/skf-rename-skill/customize.toml +52 -0
  170. package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
  171. package/src/skf-rename-skill/references/health-check.md +30 -0
  172. package/src/skf-rename-skill/references/rebuild-context.md +110 -0
  173. package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  174. package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
  175. package/src/skf-setup/SKILL.md +35 -35
  176. package/src/skf-setup/customize.toml +33 -0
  177. package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
  178. package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
  179. package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
  180. package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
  181. package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
  182. package/src/skf-setup/references/tier-rules.md +1 -1
  183. package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
  184. package/src/skf-test-skill/SKILL.md +77 -15
  185. package/src/skf-test-skill/customize.toml +54 -0
  186. package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
  187. package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
  188. package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
  189. package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
  190. package/src/skf-test-skill/references/health-check.md +14 -0
  191. package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
  192. package/src/skf-test-skill/references/migration-section-rules.md +4 -2
  193. package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
  194. package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
  195. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  196. package/src/skf-test-skill/references/source-access-protocol.md +7 -5
  197. package/src/skf-test-skill/scripts/compute-score.py +88 -17
  198. package/src/skf-test-skill/templates/test-report-template.md +13 -13
  199. package/src/skf-update-skill/SKILL.md +23 -14
  200. package/src/skf-update-skill/customize.toml +44 -0
  201. package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
  202. package/src/skf-update-skill/references/health-check.md +32 -0
  203. package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
  204. package/src/skf-update-skill/references/manual-section-rules.md +4 -0
  205. package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
  206. package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
  207. package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
  208. package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
  209. package/src/skf-update-skill/references/report.md +214 -0
  210. package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
  211. package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
  212. package/src/skf-verify-stack/SKILL.md +89 -17
  213. package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
  214. package/src/skf-verify-stack/customize.toml +50 -0
  215. package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
  216. package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
  217. package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
  218. package/src/skf-verify-stack/references/init.md +166 -0
  219. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  220. package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
  221. package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
  222. package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
  223. package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
  224. package/tools/validate-docs-drift.js +1 -1
  225. package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
  226. package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
  227. package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
  228. package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
  229. package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
  230. package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
@@ -0,0 +1,375 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Scan Skill.md Structure — deterministic structural checks for SKILL.md.
6
+
7
+ Replaces the synonym-grep loops, bash fence recipes, and inline Python
8
+ table-parser in `references/coherence-check.md` §§2.1 / 2.2 / 2.3 / 2.6 with
9
+ a single subprocess invocation that emits JSON.
10
+
11
+ Subcommands:
12
+ scan <skill-md>
13
+ Emit JSON describing fence balance, bare opening fences (no language
14
+ tag), and table column-count drift:
15
+ {
16
+ "unbalanced_fences": <bool>,
17
+ "fence_count": N,
18
+ "bare_opening_fences": [{"line": N, "text": "..."}, ...],
19
+ "table_drift": [{"line": N, "section": "<heading>",
20
+ "expected_cols": N, "actual_cols": N,
21
+ "row": "..."}]
22
+ }
23
+
24
+ scan <skill-md> --required-sections
25
+ Emit JSON describing which of the three required section families
26
+ (description / usage / api_surface) are present, and which synonym
27
+ satisfied the requirement (case-insensitive, `##`/`###` tolerated):
28
+ {
29
+ "description": {"satisfied": <bool>,
30
+ "matched_synonym": "<heading>" | null,
31
+ "tried": ["Description", "Overview", ...]},
32
+ "usage": {"satisfied": <bool>,
33
+ "matched_synonym": "..." | null,
34
+ "tried": [...]},
35
+ "api_surface": {"satisfied": <bool>,
36
+ "matched_synonym": "..." | null,
37
+ "tried": [...]}
38
+ }
39
+
40
+ Heading match rule:
41
+ Match the first `^#+\\s+<heading>$` (any number of `#`, case-insensitive,
42
+ surrounding whitespace trimmed) that matches any synonym in a family.
43
+ The reported `matched_synonym` is the canonical synonym from the list,
44
+ not the heading text as it appears in the file.
45
+
46
+ Fence balance:
47
+ Count triple-backtick (```) fence lines (`^```` at start of line, ignoring
48
+ leading whitespace). `unbalanced_fences=true` iff the count is odd.
49
+
50
+ Bare opening fence:
51
+ A stateful open/close scan toggles `in_code` on each fence line. A bare
52
+ opening fence is one where `in_code` transitions 0→1 and the line, with
53
+ the leading ``` stripped, has no language tag (empty or whitespace-only
54
+ remainder). Closing fences are never flagged — they are bare by markdown
55
+ convention. This mirrors the Python recipe in coherence-check.md §2.3.
56
+
57
+ Table drift:
58
+ Walks markdown table blocks. A block starts when a `^\\|.*\\|$` line is
59
+ found; subsequent contiguous `^\\|.*\\|$` lines are part of the same
60
+ block. The first row is the header. The second row, if it matches the
61
+ separator pattern (cells made of `-`, `:`, and whitespace), is ignored
62
+ in the drift count. For every other row, normalize escaped pipes (`\\|`)
63
+ to a sentinel before splitting on `|`, then drop the empty leading and
64
+ trailing fields produced by the bracketing pipes; flag rows whose column
65
+ count differs from the header's. Each flag includes the line number,
66
+ the most-recently-seen `^#+\\s+` heading, expected/actual column counts,
67
+ and the raw row text.
68
+
69
+ Escaped pipes appear inside TypeScript union types (e.g.
70
+ `string \\| undefined`). Normalizing prevents one false drift finding
71
+ per union-typed cell.
72
+
73
+ Empty SKILL.md:
74
+ An empty file yields `fence_count: 0`, `unbalanced_fences: false`,
75
+ empty `bare_opening_fences`, empty `table_drift`; for
76
+ `--required-sections`, all three families have `satisfied: false`,
77
+ `matched_synonym: null`.
78
+
79
+ Exit codes:
80
+ 0 — operation succeeded
81
+ 1 — user error (file not found, can't read)
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import argparse
87
+ import json
88
+ import re
89
+ import sys
90
+ from pathlib import Path
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # Required-section synonym constants
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ # These mirror the canonical synonyms documented in
99
+ # `src/skf-test-skill/references/coherence-check.md` §2.1, with the
100
+ # SKF-template-specific headings (`Quick Start`, `Common Workflows`,
101
+ # `Key API Summary`) folded in so they are first-class matches rather
102
+ # than literal-name misses (per the §2.1 "Note" paragraph).
103
+ REQUIRED_SYNONYMS: dict[str, list[str]] = {
104
+ "description": ["Description", "Overview", "Purpose", "Summary"],
105
+ "usage": [
106
+ "Usage",
107
+ "Examples",
108
+ "How to use",
109
+ "Quickstart",
110
+ "Quick Start",
111
+ "Getting Started",
112
+ "Common Workflows",
113
+ ],
114
+ "api_surface": [
115
+ "API",
116
+ "API Surface",
117
+ "Exports",
118
+ "Public API",
119
+ "Interface",
120
+ "Reference",
121
+ "Key API Summary",
122
+ ],
123
+ }
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # Required-section presence
128
+ # --------------------------------------------------------------------------
129
+
130
+
131
+ _HEADING_RE = re.compile(r"^\s*(#+)\s+(.*?)\s*$")
132
+
133
+
134
+ def find_required_sections(text: str) -> dict[str, dict]:
135
+ """For each family, find the first matching heading.
136
+
137
+ Walks every line once, lower-cases the heading text, and looks it up
138
+ against pre-lowered synonym sets. Returns the structure described in
139
+ the module docstring.
140
+ """
141
+ # Build a lookup: lowered-heading → (family, canonical_synonym)
142
+ # Multiple families can never share a synonym, so a flat dict is fine.
143
+ lookup: dict[str, tuple[str, str]] = {}
144
+ for family, synonyms in REQUIRED_SYNONYMS.items():
145
+ for syn in synonyms:
146
+ lookup[syn.lower()] = (family, syn)
147
+
148
+ matched: dict[str, str] = {}
149
+ for line in text.splitlines():
150
+ m = _HEADING_RE.match(line)
151
+ if not m:
152
+ continue
153
+ heading_text = m.group(2).strip().lower()
154
+ hit = lookup.get(heading_text)
155
+ if hit is None:
156
+ continue
157
+ family, canonical = hit
158
+ if family in matched:
159
+ # first match wins
160
+ continue
161
+ matched[family] = canonical
162
+
163
+ result: dict[str, dict] = {}
164
+ for family, synonyms in REQUIRED_SYNONYMS.items():
165
+ if family in matched:
166
+ result[family] = {
167
+ "satisfied": True,
168
+ "matched_synonym": matched[family],
169
+ "tried": list(synonyms),
170
+ }
171
+ else:
172
+ result[family] = {
173
+ "satisfied": False,
174
+ "matched_synonym": None,
175
+ "tried": list(synonyms),
176
+ }
177
+ return result
178
+
179
+
180
+ # --------------------------------------------------------------------------
181
+ # Fence balance + bare opening fences
182
+ # --------------------------------------------------------------------------
183
+
184
+
185
+ def scan_fences(text: str) -> tuple[int, bool, list[dict]]:
186
+ """Count fences, decide balance, collect bare opening fences.
187
+
188
+ Uses a stateful toggle so closing fences are never flagged.
189
+ Returns (fence_count, unbalanced, bare_opening_fences[]).
190
+ """
191
+ fence_count = 0
192
+ bare: list[dict] = []
193
+ in_code = False
194
+ for lineno, raw in enumerate(text.splitlines(), start=1):
195
+ # match lines that start with ``` (allow leading whitespace,
196
+ # which markdown tolerates in some renderers)
197
+ stripped = raw.lstrip()
198
+ if not stripped.startswith("```"):
199
+ continue
200
+ fence_count += 1
201
+ # the part of the fence line after the opening ```
202
+ tail = stripped[3:].strip()
203
+ if not in_code:
204
+ # opening fence: a bare opening fence has empty tail
205
+ if tail == "":
206
+ bare.append({"line": lineno, "text": raw})
207
+ in_code = True
208
+ else:
209
+ # closing fence — never flagged
210
+ in_code = False
211
+ return fence_count, (fence_count % 2 == 1), bare
212
+
213
+
214
+ # --------------------------------------------------------------------------
215
+ # Table column drift
216
+ # --------------------------------------------------------------------------
217
+
218
+
219
+ _TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
220
+ _TABLE_SEP_CELL_RE = re.compile(r"^\s*:?-+:?\s*$")
221
+ _PIPE_SENTINEL = "\x00"
222
+
223
+
224
+ def _split_row_cells(row_text: str) -> list[str]:
225
+ """Split a markdown table row into cells.
226
+
227
+ Normalizes escaped pipes to a sentinel before splitting, then drops
228
+ the empty leading/trailing fields produced by the bracketing pipes.
229
+ """
230
+ normalized = row_text.strip().replace("\\|", _PIPE_SENTINEL)
231
+ parts = normalized.split("|")
232
+ # parts looks like ["", "cell1", "cell2", ..., ""] for `|a|b|`;
233
+ # drop bracketing empties.
234
+ if parts and parts[0].strip() == "":
235
+ parts = parts[1:]
236
+ if parts and parts[-1].strip() == "":
237
+ parts = parts[:-1]
238
+ return [p.replace(_PIPE_SENTINEL, "|") for p in parts]
239
+
240
+
241
+ def _is_separator_row(cells: list[str]) -> bool:
242
+ """True if every cell looks like a table separator (`---`, `:---:`, etc.)."""
243
+ if not cells:
244
+ return False
245
+ return all(_TABLE_SEP_CELL_RE.match(c) is not None for c in cells)
246
+
247
+
248
+ def find_table_drift(text: str) -> list[dict]:
249
+ """Walk table blocks, flag rows whose column count differs from the header.
250
+
251
+ Tracks the most-recently-seen heading text so each finding can name the
252
+ section it's in. The heading text reported is the raw text after the `#`
253
+ characters, with surrounding whitespace stripped.
254
+ """
255
+ findings: list[dict] = []
256
+ lines = text.splitlines()
257
+ current_section = ""
258
+ i = 0
259
+ while i < len(lines):
260
+ line = lines[i]
261
+ heading = _HEADING_RE.match(line)
262
+ if heading is not None:
263
+ current_section = heading.group(2).strip()
264
+ i += 1
265
+ continue
266
+ if not _TABLE_ROW_RE.match(line):
267
+ i += 1
268
+ continue
269
+
270
+ # Start of a table block. Collect contiguous rows.
271
+ block_start = i
272
+ block_rows: list[tuple[int, str]] = []
273
+ while i < len(lines) and _TABLE_ROW_RE.match(lines[i]):
274
+ block_rows.append((i + 1, lines[i])) # 1-based line numbers
275
+ i += 1
276
+
277
+ if not block_rows:
278
+ continue
279
+
280
+ header_lineno, header_text = block_rows[0]
281
+ header_cells = _split_row_cells(header_text)
282
+ expected = len(header_cells)
283
+
284
+ # If the second row is a separator, skip it from drift checking.
285
+ body_rows = block_rows[1:]
286
+ if body_rows:
287
+ _, second_text = body_rows[0]
288
+ if _is_separator_row(_split_row_cells(second_text)):
289
+ body_rows = body_rows[1:]
290
+
291
+ for row_lineno, row_text in body_rows:
292
+ cells = _split_row_cells(row_text)
293
+ actual = len(cells)
294
+ if actual != expected:
295
+ findings.append({
296
+ "line": row_lineno,
297
+ "section": current_section,
298
+ "expected_cols": expected,
299
+ "actual_cols": actual,
300
+ "row": row_text,
301
+ })
302
+
303
+ # if the block ended at a non-row line, fall through to advance i;
304
+ # i already points past the block.
305
+ _ = block_start # explicitly unused, kept for readability
306
+ return findings
307
+
308
+
309
+ # --------------------------------------------------------------------------
310
+ # CLI
311
+ # --------------------------------------------------------------------------
312
+
313
+
314
+ def _read_text(path: Path) -> str:
315
+ return path.read_text(encoding="utf-8")
316
+
317
+
318
+ def _cmd_scan(args: argparse.Namespace) -> int:
319
+ skill_md = Path(args.skill_md)
320
+ if not skill_md.is_file():
321
+ print(f"error: file not found: {skill_md}", file=sys.stderr)
322
+ return 1
323
+ try:
324
+ text = _read_text(skill_md)
325
+ except OSError as exc:
326
+ print(f"error: cannot read {skill_md}: {exc}", file=sys.stderr)
327
+ return 1
328
+
329
+ if args.required_sections:
330
+ payload = find_required_sections(text)
331
+ else:
332
+ fence_count, unbalanced, bare = scan_fences(text)
333
+ payload = {
334
+ "unbalanced_fences": unbalanced,
335
+ "fence_count": fence_count,
336
+ "bare_opening_fences": bare,
337
+ "table_drift": find_table_drift(text),
338
+ }
339
+
340
+ json.dump(payload, sys.stdout, indent=2)
341
+ sys.stdout.write("\n")
342
+ return 0
343
+
344
+
345
+ def _build_parser() -> argparse.ArgumentParser:
346
+ parser = argparse.ArgumentParser(
347
+ prog="skf-scan-skill-md-structure",
348
+ description=(
349
+ "Deterministic structural scans for SKILL.md: fence balance, "
350
+ "bare opening fences, table column drift, and required-section "
351
+ "presence (case-insensitive synonym match)."
352
+ ),
353
+ )
354
+ sub = parser.add_subparsers(dest="cmd", required=True)
355
+
356
+ p_scan = sub.add_parser("scan", help="emit structural scan JSON")
357
+ p_scan.add_argument("skill_md", help="path to a SKILL.md file")
358
+ p_scan.add_argument(
359
+ "--required-sections",
360
+ action="store_true",
361
+ help="emit required-section presence JSON instead of fence/table data",
362
+ )
363
+ p_scan.set_defaults(func=_cmd_scan)
364
+
365
+ return parser
366
+
367
+
368
+ def main(argv: list[str] | None = None) -> int:
369
+ parser = _build_parser()
370
+ args = parser.parse_args(argv)
371
+ return args.func(args)
372
+
373
+
374
+ if __name__ == "__main__":
375
+ raise SystemExit(main())
@@ -0,0 +1,315 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Update Active Symlink — atomic + idempotent + verified flip of
6
+ {skill_group}/active to point at a target version directory.
7
+
8
+ `skf-update-skill/references/write.md §5b` previously asked the LLM to
9
+ "create or update the active symlink at {skill_group}/active pointing
10
+ to the new {version}; if the symlink already exists, remove it first
11
+ and recreate". That two-step (`rm` then `ln`) leaves a brief window
12
+ where the symlink doesn't exist — readers that resolve `active`
13
+ mid-update see a `FileNotFoundError`. §6 then re-reads the symlink
14
+ to verify it points where expected and halts on divergence.
15
+
16
+ This helper consolidates the two steps into one workflow-specific
17
+ invocation:
18
+
19
+ - detects whether a flip is required (idempotent: already pointing
20
+ at the target → no-op)
21
+ - performs the flip atomically (symlink → temp name → os.replace)
22
+ so concurrent readers never see a missing symlink
23
+ - verifies the post-state and surfaces ok / flipped / mismatch
24
+ / missing-target
25
+
26
+ Two subcommands:
27
+
28
+ update --skill-group <path> --version <name>
29
+ Make {skill-group}/active point at {version}. Atomic.
30
+ Idempotent: returns status=ok with action=no-op when the link
31
+ already points there.
32
+
33
+ verify --skill-group <path> --version <name>
34
+ Read-only: check that {skill-group}/active currently points at
35
+ {version}. Used by step-06 derived-artifact verification.
36
+
37
+ The target version directory (`{skill-group}/{version}/`) MUST exist
38
+ on disk before this script is called. The helper refuses to point
39
+ the symlink at a path that doesn't resolve — a dangling `active`
40
+ silently breaks downstream consumers that expect it to resolve.
41
+
42
+ Output JSON (stdout):
43
+ {
44
+ "status": "ok" | "flipped" | "mismatch" | "missing-target",
45
+ "skill_group": "<abs path>",
46
+ "active_link": "<abs path to active>",
47
+ "expected_target": "<version>",
48
+ "current_target": "<previous target>" | null,
49
+ "action_taken": "no-op" | "flipped" | "halt",
50
+ "log_message": "...",
51
+ "halt_message": "<multi-line user message>" | null
52
+ }
53
+
54
+ Exit codes:
55
+ 0 — ok (already correct) or flipped (just updated)
56
+ 1 — script error (bad args, group path invalid)
57
+ 2 — mismatch (verify mode and target diverges) OR missing-target
58
+ (the {version} directory doesn't exist; caller must halt)
59
+
60
+ Platform: POSIX symlinks. On Windows this script intentionally
61
+ exits 1 with a clear error — update-skill's documented supported
62
+ platform is WSL2 (same as skf-atomic-write.py's flip-link).
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import argparse
68
+ import json
69
+ import os
70
+ import sys
71
+ from pathlib import Path
72
+
73
+
74
+ ACTIVE_NAME = "active"
75
+
76
+
77
+ # --------------------------------------------------------------------------
78
+ # Symlink primitives
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ def read_link_target(link_path: Path) -> str | None:
83
+ """Return the symlink target as a relative string, or None if the link
84
+ doesn't exist. Raises ValueError if `link_path` exists but isn't a symlink
85
+ (someone replaced it with a regular dir/file)."""
86
+ if not link_path.exists() and not link_path.is_symlink():
87
+ return None
88
+ if not link_path.is_symlink():
89
+ raise ValueError(
90
+ f"{link_path} exists but is not a symlink — refusing to overwrite "
91
+ "(manual recovery required)"
92
+ )
93
+ return os.readlink(link_path)
94
+
95
+
96
+ def atomic_flip_symlink(link_path: Path, target_name: str) -> None:
97
+ """Atomically point `link_path` at `target_name` using the
98
+ create-temp + rename pattern. `target_name` is a relative path
99
+ interpreted in the parent of `link_path` (same convention as
100
+ {skill_group}/active → version-dir-sibling).
101
+
102
+ Implementation: os.symlink writes a new link at link_path.tmp,
103
+ then os.replace atomically renames it over link_path. POSIX
104
+ rename is atomic for symlinks, so concurrent readers either see
105
+ the old target or the new one, never a missing file.
106
+ """
107
+ parent = link_path.parent
108
+ tmp = parent / f".{link_path.name}.skf-symlink.tmp"
109
+ # Clean up a stale temp from a prior crash, if any.
110
+ if tmp.is_symlink() or tmp.exists():
111
+ os.unlink(tmp)
112
+ try:
113
+ os.symlink(target_name, tmp)
114
+ os.replace(tmp, link_path)
115
+ except OSError:
116
+ # Best-effort cleanup; raise the original error.
117
+ try:
118
+ if tmp.is_symlink() or tmp.exists():
119
+ os.unlink(tmp)
120
+ except OSError:
121
+ pass
122
+ raise
123
+
124
+
125
+ # --------------------------------------------------------------------------
126
+ # Core operations
127
+ # --------------------------------------------------------------------------
128
+
129
+
130
+ def _envelope_missing_target(skill_group: Path, version: str) -> dict:
131
+ return {
132
+ "status": "missing-target",
133
+ "skill_group": str(skill_group),
134
+ "active_link": str(skill_group / ACTIVE_NAME),
135
+ "expected_target": version,
136
+ "current_target": None,
137
+ "action_taken": "halt",
138
+ "log_message": (
139
+ f"active_symlink_update: missing-target "
140
+ f"({skill_group / version} does not exist)"
141
+ ),
142
+ "halt_message": (
143
+ f"Cannot point {skill_group / ACTIVE_NAME} at `{version}` — "
144
+ f"target directory `{skill_group / version}` does not exist on "
145
+ "disk. Verify the version directory was written before §5b runs."
146
+ ),
147
+ }
148
+
149
+
150
+ def _envelope_ok(skill_group: Path, version: str, current: str | None) -> dict:
151
+ return {
152
+ "status": "ok",
153
+ "skill_group": str(skill_group),
154
+ "active_link": str(skill_group / ACTIVE_NAME),
155
+ "expected_target": version,
156
+ "current_target": current,
157
+ "action_taken": "no-op",
158
+ "log_message": f"active_symlink_update: ok ({version})",
159
+ "halt_message": None,
160
+ }
161
+
162
+
163
+ def _envelope_flipped(skill_group: Path, version: str, previous: str | None) -> dict:
164
+ prev = previous if previous is not None else "(none)"
165
+ return {
166
+ "status": "flipped",
167
+ "skill_group": str(skill_group),
168
+ "active_link": str(skill_group / ACTIVE_NAME),
169
+ "expected_target": version,
170
+ "current_target": version,
171
+ "action_taken": "flipped",
172
+ "log_message": f"active_symlink_update: flipped ({prev} -> {version})",
173
+ "halt_message": None,
174
+ }
175
+
176
+
177
+ def _envelope_mismatch(skill_group: Path, version: str, current: str | None) -> dict:
178
+ cur = current if current is not None else "(none)"
179
+ return {
180
+ "status": "mismatch",
181
+ "skill_group": str(skill_group),
182
+ "active_link": str(skill_group / ACTIVE_NAME),
183
+ "expected_target": version,
184
+ "current_target": current,
185
+ "action_taken": "halt",
186
+ "log_message": (
187
+ f"active_symlink_update: mismatch "
188
+ f"(expected={version}, current={cur})"
189
+ ),
190
+ "halt_message": (
191
+ f"Active symlink divergence. `{skill_group / ACTIVE_NAME}` "
192
+ f"resolves to `{cur}` but metadata.json reports `version: "
193
+ f"{version}`. §5b did not apply. Re-point the symlink manually "
194
+ f"(`ln -sfn {version} {skill_group / ACTIVE_NAME}`) or re-run "
195
+ "update-skill, then re-verify."
196
+ ),
197
+ }
198
+
199
+
200
+ def update(skill_group: Path, version: str) -> dict:
201
+ """Idempotently flip {skill_group}/active to point at {version}."""
202
+ target_dir = skill_group / version
203
+ if not target_dir.is_dir():
204
+ return _envelope_missing_target(skill_group, version)
205
+
206
+ link_path = skill_group / ACTIVE_NAME
207
+ current = read_link_target(link_path)
208
+ if current == version:
209
+ return _envelope_ok(skill_group, version, current)
210
+
211
+ atomic_flip_symlink(link_path, version)
212
+
213
+ # Re-read to confirm
214
+ post = read_link_target(link_path)
215
+ if post != version:
216
+ # Should be impossible — os.replace would have raised — but defend
217
+ return _envelope_mismatch(skill_group, version, post)
218
+ return _envelope_flipped(skill_group, version, current)
219
+
220
+
221
+ def verify(skill_group: Path, version: str) -> dict:
222
+ """Read-only check: assert active points at version."""
223
+ link_path = skill_group / ACTIVE_NAME
224
+ current = read_link_target(link_path)
225
+ if current == version:
226
+ return _envelope_ok(skill_group, version, current)
227
+ return _envelope_mismatch(skill_group, version, current)
228
+
229
+
230
+ # --------------------------------------------------------------------------
231
+ # CLI
232
+ # --------------------------------------------------------------------------
233
+
234
+
235
+ def _exit_code_for_status(status: str) -> int:
236
+ if status in ("ok", "flipped"):
237
+ return 0
238
+ if status in ("mismatch", "missing-target"):
239
+ return 2
240
+ return 1
241
+
242
+
243
+ def _cmd_update(args: argparse.Namespace) -> int:
244
+ skill_group = Path(args.skill_group)
245
+ if not skill_group.is_dir():
246
+ print(
247
+ f"error: skill-group not a directory: {skill_group}", file=sys.stderr
248
+ )
249
+ return 1
250
+ try:
251
+ result = update(skill_group, args.version)
252
+ except (ValueError, OSError) as exc:
253
+ print(f"error: {exc}", file=sys.stderr)
254
+ return 1
255
+ json.dump(result, sys.stdout, indent=2)
256
+ sys.stdout.write("\n")
257
+ return _exit_code_for_status(result["status"])
258
+
259
+
260
+ def _cmd_verify(args: argparse.Namespace) -> int:
261
+ skill_group = Path(args.skill_group)
262
+ if not skill_group.is_dir():
263
+ print(
264
+ f"error: skill-group not a directory: {skill_group}", file=sys.stderr
265
+ )
266
+ return 1
267
+ try:
268
+ result = verify(skill_group, args.version)
269
+ except (ValueError, OSError) as exc:
270
+ print(f"error: {exc}", file=sys.stderr)
271
+ return 1
272
+ json.dump(result, sys.stdout, indent=2)
273
+ sys.stdout.write("\n")
274
+ return _exit_code_for_status(result["status"])
275
+
276
+
277
+ def _build_parser() -> argparse.ArgumentParser:
278
+ parser = argparse.ArgumentParser(
279
+ prog="skf-update-active-symlink",
280
+ description=(
281
+ "Atomically + idempotently update {skill-group}/active to point "
282
+ "at a target version directory."
283
+ ),
284
+ )
285
+ sub = parser.add_subparsers(dest="cmd", required=True)
286
+
287
+ p_upd = sub.add_parser("update", help="flip the symlink if needed")
288
+ p_upd.add_argument("--skill-group", required=True, help="path to the skill_group dir")
289
+ p_upd.add_argument("--version", required=True, help="version directory name")
290
+ p_upd.set_defaults(func=_cmd_update)
291
+
292
+ p_ver = sub.add_parser("verify", help="read-only: assert symlink matches version")
293
+ p_ver.add_argument("--skill-group", required=True)
294
+ p_ver.add_argument("--version", required=True)
295
+ p_ver.set_defaults(func=_cmd_verify)
296
+
297
+ return parser
298
+
299
+
300
+ def main(argv: list[str] | None = None) -> int:
301
+ if sys.platform.startswith("win"):
302
+ print(
303
+ "error: native Windows symlinks require admin/developer mode; "
304
+ "use WSL2 to run update-skill (same constraint as "
305
+ "skf-atomic-write.py flip-link)",
306
+ file=sys.stderr,
307
+ )
308
+ return 1
309
+ parser = _build_parser()
310
+ args = parser.parse_args(argv)
311
+ return args.func(args)
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(main())