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
@@ -4,9 +4,9 @@
4
4
  # ///
5
5
  """SKF Emit Result Envelope — Schema-locked headless output for skf-setup.
6
6
 
7
- Replaces the prose-driven envelope assembly in `src/skf-setup/steps-c/
8
- step-04-report.md` §4 with one Python invocation. The envelope contract
9
- (SKF_SETUP_RESULT_JSON) was added to step-04 in PR #247, extended in
7
+ Replaces the prose-driven envelope assembly in `src/skf-setup/references/
8
+ report.md` §4 with one Python invocation. The envelope contract
9
+ (SKF_SETUP_RESULT_JSON) was added to step 4 in PR #247, extended in
10
10
  PR #248 (header / outputs / failure modes), and extended again in this
11
11
  PR (previous_tier, tier_changed, tools_added, tools_removed, error).
12
12
 
@@ -89,7 +89,7 @@ SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.
89
89
  TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
90
90
  VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
91
91
  VALID_FILES = ("forge-tier.yaml", "preferences.yaml", "settings.yml", "ccc_index")
92
- VALID_CCC_STATUS = ("fresh", "created", "failed", "none")
92
+ VALID_CCC_STATUS = ("fresh", "created", "failed", "none", "skipped")
93
93
 
94
94
 
95
95
  def _die(code: int, message: str) -> None:
@@ -132,7 +132,7 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
132
132
  """Return (added, removed) tool-key lists, sorted for determinism.
133
133
 
134
134
  First-run convention (previous is None or empty): added = currently
135
- available tools; removed = []. Matches the documented step-04 §4 rule.
135
+ available tools; removed = []. Matches the documented step 4 §4 rule.
136
136
  """
137
137
  cur = _normalize_tools(current)
138
138
  if not previous:
@@ -147,7 +147,7 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
147
147
  def _assemble_warnings(payload: dict) -> list[str]:
148
148
  """Fold every documented warning source into the envelope's warnings array.
149
149
 
150
- Matches the field-rules block in step-04 §4 — pipelines should only need
150
+ Matches the field-rules block in step 4 §4 — pipelines should only need
151
151
  to consult `warnings` to surface non-fatal issues.
152
152
  """
153
153
  warnings: list[str] = []
@@ -253,8 +253,12 @@ def assemble_envelope(payload: dict) -> dict:
253
253
  if require_tier_satisfied is not None and not isinstance(require_tier_satisfied, bool):
254
254
  _die(1, f"require_tier_satisfied must be bool or null, got {type(require_tier_satisfied).__name__}")
255
255
 
256
+ error = _normalize_error(payload.get("error"))
257
+ status = _compute_status(error, require_tier_satisfied)
258
+
256
259
  return {
257
260
  "skf_setup": {
261
+ "status": status,
258
262
  "tier": tier,
259
263
  "previous_tier": previous_tier,
260
264
  "tier_changed": tier_changed,
@@ -268,11 +272,29 @@ def assemble_envelope(payload: dict) -> dict:
268
272
  "tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
269
273
  "require_tier_satisfied": require_tier_satisfied,
270
274
  "warnings": _assemble_warnings(payload),
271
- "error": _normalize_error(payload.get("error")),
275
+ "error": error,
272
276
  }
273
277
  }
274
278
 
275
279
 
280
+ def _compute_status(error: dict | None, require_tier_satisfied) -> str:
281
+ """Derive the single-field status from error + require_tier_satisfied.
282
+
283
+ - 'write_failure' when error.phase signals a file-write failure
284
+ - 'blocked' when error is non-null but not a write failure
285
+ - 'tier_failure' when require_tier_satisfied is False
286
+ - 'success' otherwise
287
+ """
288
+ if error is not None:
289
+ phase = (error.get("phase") or "").lower()
290
+ if "write" in phase or phase.endswith("forge-tier.yaml") or phase.endswith("preferences.yaml"):
291
+ return "write_failure"
292
+ return "blocked"
293
+ if require_tier_satisfied is False:
294
+ return "tier_failure"
295
+ return "success"
296
+
297
+
276
298
  def emit_envelope_line(envelope: dict) -> str:
277
299
  """Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism."""
278
300
  body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
@@ -398,19 +420,81 @@ def cmd_validate() -> None:
398
420
  _die(1, "; ".join(errors))
399
421
 
400
422
 
423
+ def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None) -> dict:
424
+ """Assemble a minimal status='blocked' envelope for early-halt paths.
425
+
426
+ Used when On Activation or step 2 halts before the regular envelope
427
+ construction has the inputs it needs (no tier, no detected tools,
428
+ no config_path). Every other required field gets a documented sentinel
429
+ so the envelope still passes schema validation and pipelines can
430
+ branch on `status: "blocked"` and inspect `error` for context.
431
+ """
432
+ return {
433
+ "skf_setup": {
434
+ "status": "blocked",
435
+ "tier": "Quick",
436
+ "previous_tier": None,
437
+ "tier_changed": False,
438
+ "tools": {"ast_grep": False, "gh_cli": False, "qmd": False, "ccc": False},
439
+ "tools_added": [],
440
+ "tools_removed": [],
441
+ "config_path": path or "<unknown — halt before config_path resolved>",
442
+ "ccc_index": {"status": "none", "indexed_path": None, "file_count": None},
443
+ "files_written": [],
444
+ "tier_override_active": False,
445
+ "tier_override_invalid": False,
446
+ "require_tier_satisfied": None,
447
+ "warnings": [],
448
+ "error": {
449
+ "phase": phase,
450
+ "path": path or "<n/a>",
451
+ "reason": reason,
452
+ },
453
+ }
454
+ }
455
+
456
+
457
+ def cmd_emit_blocked() -> None:
458
+ """Emit a blocked envelope. Reads `phase`, `reason`, optional `path` from stdin JSON.
459
+
460
+ Designed for early-halt paths (uv missing, config.yaml missing, etc.) where
461
+ the regular `emit` subcommand can't run because tier/tools/config_path are
462
+ not yet known.
463
+ """
464
+ payload = _read_stdin_json("emit-blocked")
465
+ phase = payload.get("phase")
466
+ reason = payload.get("reason")
467
+ if not isinstance(phase, str) or not phase:
468
+ _die(1, "emit-blocked: 'phase' must be a non-empty string")
469
+ if not isinstance(reason, str) or not reason:
470
+ _die(1, "emit-blocked: 'reason' must be a non-empty string")
471
+ path = payload.get("path")
472
+ if path is not None and not isinstance(path, str):
473
+ _die(1, "emit-blocked: 'path' must be a string or omitted")
474
+ envelope = assemble_blocked_envelope(phase, reason, path)
475
+ schema = _load_schema()
476
+ errors = _validate_against_schema(envelope, schema)
477
+ if errors:
478
+ _die(2, f"assembled blocked envelope failed schema validation: {errors}")
479
+ print(emit_envelope_line(envelope))
480
+
481
+
401
482
  def main() -> None:
402
483
  parser = argparse.ArgumentParser(
403
484
  description=__doc__,
404
485
  formatter_class=argparse.RawDescriptionHelpFormatter,
405
486
  )
406
487
  sub = parser.add_subparsers(dest="cmd")
407
- sub.add_parser("emit", help="Build envelope from context payload (default).")
408
- sub.add_parser("validate", help="Validate an envelope payload against the schema.")
488
+ sub.add_parser("emit", help="Build envelope from context payload (default).")
489
+ sub.add_parser("emit-blocked", help="Emit minimal status='blocked' envelope for early-halt paths.")
490
+ sub.add_parser("validate", help="Validate an envelope payload against the schema.")
409
491
  args = parser.parse_args()
410
492
 
411
493
  cmd = args.cmd or "emit"
412
494
  if cmd == "emit":
413
495
  cmd_emit()
496
+ elif cmd == "emit-blocked":
497
+ cmd_emit_blocked()
414
498
  elif cmd == "validate":
415
499
  cmd_validate()
416
500
 
@@ -0,0 +1,514 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Enumerate Stack Skills — inventory skill packages + resolve exports.
6
+
7
+ Replaces the LLM-orchestrated "Exports resolution order" cascade in
8
+ `src/skf-create-stack-skill/references/parallel-extract.md` §0. The stage
9
+ prose previously asked the model to walk three fallback sources per
10
+ constituent skill (metadata.json → references/ → SKILL.md prose) and emit
11
+ the cascade decisions inline. That's an N-way implicit-read trap: for a
12
+ compose-mode stack with K skills the model must read up to K × 3 files
13
+ just to settle the inventory, before any actual per-skill usage analysis
14
+ runs.
15
+
16
+ This helper does the deterministic part once: scan the skills root,
17
+ resolve each skill's `exports[]` via the cascade, hash metadata.json
18
+ where present, and emit a structured JSON inventory. The per-skill
19
+ subagent fan-out at §1+ of parallel-extract.md remains unchanged — the
20
+ subprocesses read this inventory's cached result instead of re-reading
21
+ SKILL.md, then extract usage patterns per skill (the part that does
22
+ benefit from LLM judgment).
23
+
24
+ Subcommand:
25
+ enumerate <skills-root>
26
+ Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
27
+ describing every subdirectory of <skills-root> that contains a
28
+ SKILL.md. Each entry carries:
29
+
30
+ name — skill name (subdirectory name)
31
+ path — relative path under skills-root, forward-slash
32
+ exports — exports list resolved via cascade
33
+ exports_source — "metadata|references|skill-md|unknown"
34
+ confidence — "T1|T2|T1-low" (mapped from exports_source)
35
+ metadata_hash — sha256: prefix on metadata.json digest, or null
36
+
37
+ Exports resolution cascade (must match parallel-extract.md §0):
38
+
39
+ 1. metadata.json — if present, valid JSON, and contains an `exports[]`
40
+ array: use the list, mark exports_source="metadata", compute
41
+ metadata_hash = sha256: + sha256(metadata.json content).
42
+ The metadata.json hash is computed even if exports[] is empty —
43
+ callers may want to detect "exports intentionally empty" vs
44
+ "no metadata at all".
45
+
46
+ 2. references/*.md — scan top-level `*.md` files under references/
47
+ for an `## API` or `## Exports` section. Bulleted list items in
48
+ that section become exports. Set exports_source="references",
49
+ metadata_hash=null.
50
+
51
+ 3. SKILL.md prose — look for an `## Exports` or `## API Surface`
52
+ section and parse its bulleted list. Set exports_source="skill-md",
53
+ metadata_hash=null.
54
+
55
+ 4. None of the above — exports=[], exports_source="unknown",
56
+ confidence="T1-low", append a warning
57
+ `"<skill-name>: no exports found via any resolution path"`.
58
+
59
+ Confidence mapping:
60
+ metadata → T1 (structured manifest, trustworthy)
61
+ references → T2 (heuristic reconstruction, lower trust)
62
+ skill-md → T2 (prose parsing, comparable trust to references)
63
+ unknown → T1-low (degraded extraction; nothing found)
64
+
65
+ Cycle detection:
66
+
67
+ metadata.json may declare `composes: ["<skill-name>", ...]` referencing
68
+ other skill packages in this skills-root (compose mode). If A composes
69
+ B and B composes A — directly or transitively — append the cycle's
70
+ starting node to `cycles[]` and emit a warning. Cycles do not abort
71
+ enumeration; the inventory still lists every skill.
72
+
73
+ Symlink handling:
74
+
75
+ A skill directory may itself be a symlink (e.g. an "active" pointer).
76
+ If the target is unreadable (broken symlink, permission denied, etc.),
77
+ emit a warning and skip the entry — do not crash the whole scan.
78
+
79
+ Per-skill errors (malformed metadata.json, OSError, etc.) are captured
80
+ as warnings on the top-level result; they do not exit the process.
81
+
82
+ Exit codes:
83
+ 0 enumeration succeeded (including zero skills found)
84
+ 1 user error (bad skills-root path)
85
+
86
+ CLI:
87
+ uv run skf-enumerate-stack-skills.py enumerate <skills-root>
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import argparse
93
+ import hashlib
94
+ import json
95
+ import re
96
+ import sys
97
+ from pathlib import Path
98
+
99
+
100
+ # --------------------------------------------------------------------------
101
+ # Constants
102
+ # --------------------------------------------------------------------------
103
+
104
+
105
+ SOURCE_METADATA = "metadata"
106
+ SOURCE_REFERENCES = "references"
107
+ SOURCE_SKILL_MD = "skill-md"
108
+ SOURCE_UNKNOWN = "unknown"
109
+
110
+ _CONFIDENCE_BY_SOURCE = {
111
+ SOURCE_METADATA: "T1",
112
+ SOURCE_REFERENCES: "T2",
113
+ SOURCE_SKILL_MD: "T2",
114
+ SOURCE_UNKNOWN: "T1-low",
115
+ }
116
+
117
+ # Section headings recognised in the references/ cascade and the SKILL.md
118
+ # cascade. Case-sensitive — these are the canonical SKF heading forms
119
+ # documented in parallel-extract.md §0.
120
+ _REFERENCES_HEADINGS = ("## API", "## Exports")
121
+ _SKILL_MD_HEADINGS = ("## Exports", "## API Surface")
122
+
123
+ # Bulleted list-item pattern. Permissive: `- foo`, `* foo`, `+ foo`, with
124
+ # optional surrounding backticks on the name. We capture the first
125
+ # token-like identifier as the export name and stop at whitespace,
126
+ # punctuation, or the end-of-backtick.
127
+ _LIST_ITEM_RE = re.compile(
128
+ r"^\s*[-*+]\s+`?([A-Za-z_][A-Za-z0-9_.]*)`?",
129
+ )
130
+
131
+ # H2 boundary — any `## ...` line ends the current section.
132
+ _H2_BOUNDARY_RE = re.compile(r"^## ")
133
+
134
+
135
+ # --------------------------------------------------------------------------
136
+ # Hashing helper
137
+ # --------------------------------------------------------------------------
138
+
139
+
140
+ def _sha256_of_bytes(data: bytes) -> str:
141
+ """SHA-256 hex digest with `sha256:` prefix (SKF convention)."""
142
+ return "sha256:" + hashlib.sha256(data).hexdigest()
143
+
144
+
145
+ # --------------------------------------------------------------------------
146
+ # Resolution: metadata.json
147
+ # --------------------------------------------------------------------------
148
+
149
+
150
+ def _normalize_exports(raw) -> list[str]:
151
+ """Coerce an exports[] payload (list of strings OR list of {name,...})
152
+ to a flat deduplicated list of names in declaration order.
153
+
154
+ Matches skf-render-quick-metadata.py's normalisation so the cascade
155
+ is consistent across the codebase.
156
+ """
157
+ out: list[str] = []
158
+ seen: set[str] = set()
159
+ if not isinstance(raw, list):
160
+ return out
161
+ for item in raw:
162
+ name: str | None = None
163
+ if isinstance(item, str):
164
+ name = item
165
+ elif isinstance(item, dict):
166
+ v = item.get("name")
167
+ if isinstance(v, str):
168
+ name = v
169
+ if name and name not in seen:
170
+ seen.add(name)
171
+ out.append(name)
172
+ return out
173
+
174
+
175
+ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str]]:
176
+ """Try to resolve exports from metadata.json.
177
+
178
+ Returns (exports, metadata_hash, composes, warnings).
179
+ exports — list of names if metadata.json was readable AND
180
+ contained an `exports[]` array (may be empty);
181
+ None if metadata.json absent or malformed.
182
+ metadata_hash — sha256: digest of metadata.json content, or None
183
+ if file absent / unreadable.
184
+ composes — list of constituent skill names (for cycle
185
+ detection); empty list if absent.
186
+ warnings — per-skill warnings (malformed JSON, etc.)
187
+ """
188
+ meta_path = skill_dir / "metadata.json"
189
+ if not meta_path.is_file():
190
+ return None, None, [], []
191
+
192
+ warnings: list[str] = []
193
+ try:
194
+ raw_bytes = meta_path.read_bytes()
195
+ except OSError as exc:
196
+ warnings.append(f"failed to read metadata.json: {exc}")
197
+ return None, None, [], warnings
198
+
199
+ metadata_hash = _sha256_of_bytes(raw_bytes)
200
+
201
+ try:
202
+ data = json.loads(raw_bytes.decode("utf-8"))
203
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
204
+ warnings.append(f"metadata.json is not valid JSON: {exc}")
205
+ # Hash is still useful — record it so callers can correlate
206
+ # malformed files across runs.
207
+ return None, metadata_hash, [], warnings
208
+
209
+ if not isinstance(data, dict):
210
+ warnings.append("metadata.json root is not an object")
211
+ return None, metadata_hash, [], warnings
212
+
213
+ composes_raw = data.get("composes")
214
+ composes = [c for c in composes_raw if isinstance(c, str)] if isinstance(composes_raw, list) else []
215
+
216
+ if "exports" not in data:
217
+ # metadata.json exists and is valid JSON but lacks an exports
218
+ # field — fall through to references/ cascade. Carry the hash
219
+ # forward so callers see metadata existed.
220
+ return None, metadata_hash, composes, warnings
221
+
222
+ exports = _normalize_exports(data.get("exports"))
223
+ return exports, metadata_hash, composes, warnings
224
+
225
+
226
+ # --------------------------------------------------------------------------
227
+ # Resolution: references/ and SKILL.md
228
+ # --------------------------------------------------------------------------
229
+
230
+
231
+ def _parse_list_section(content: str, headings: tuple[str, ...]) -> list[str]:
232
+ """Extract bulleted items from the first matching `## <heading>` section.
233
+
234
+ Returns names in document order, deduplicated. Returns [] if no
235
+ matching heading is present.
236
+
237
+ Section boundaries:
238
+ - starts on the first line equal to one of `headings` (case-sensitive)
239
+ - ends on the next `## ` h2 heading or end-of-file
240
+ """
241
+ out: list[str] = []
242
+ seen: set[str] = set()
243
+ in_section = False
244
+ lines = content.splitlines()
245
+ for line in lines:
246
+ stripped = line.rstrip()
247
+ if not in_section:
248
+ if stripped in headings:
249
+ in_section = True
250
+ continue
251
+ # In-section. Boundary check first, then list-item parse.
252
+ if _H2_BOUNDARY_RE.match(line):
253
+ break
254
+ m = _LIST_ITEM_RE.match(line)
255
+ if m:
256
+ name = m.group(1)
257
+ if name not in seen:
258
+ seen.add(name)
259
+ out.append(name)
260
+ return out
261
+
262
+
263
+ def _resolve_from_references(skill_dir: Path) -> list[str]:
264
+ """Scan references/*.md for `## API` / `## Exports` sections.
265
+
266
+ Files are walked in sorted order (deterministic across runs). The
267
+ union of bulleted items from all matching sections is returned,
268
+ deduplicated, in scan order.
269
+ """
270
+ refs_dir = skill_dir / "references"
271
+ if not refs_dir.is_dir():
272
+ return []
273
+ out: list[str] = []
274
+ seen: set[str] = set()
275
+ for md_path in sorted(refs_dir.glob("*.md")):
276
+ try:
277
+ content = md_path.read_text(encoding="utf-8")
278
+ except OSError:
279
+ continue
280
+ for name in _parse_list_section(content, _REFERENCES_HEADINGS):
281
+ if name not in seen:
282
+ seen.add(name)
283
+ out.append(name)
284
+ return out
285
+
286
+
287
+ def _resolve_from_skill_md(skill_dir: Path) -> list[str]:
288
+ """Parse SKILL.md for an `## Exports` / `## API Surface` section."""
289
+ skill_md = skill_dir / "SKILL.md"
290
+ if not skill_md.is_file():
291
+ return []
292
+ try:
293
+ content = skill_md.read_text(encoding="utf-8")
294
+ except OSError:
295
+ return []
296
+ return _parse_list_section(content, _SKILL_MD_HEADINGS)
297
+
298
+
299
+ # --------------------------------------------------------------------------
300
+ # Per-skill resolution (cascade orchestrator)
301
+ # --------------------------------------------------------------------------
302
+
303
+
304
+ def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], list[str]]:
305
+ """Build the result entry for a single skill directory.
306
+
307
+ Returns (entry, composes, warnings).
308
+ entry — the dict that will land in result["skills"][]
309
+ composes — list of skill names this one declares as composed (cycle input)
310
+ warnings — strings to append to result["warnings"][]
311
+ """
312
+ warnings: list[str] = []
313
+
314
+ # 1. metadata.json
315
+ exports, metadata_hash, composes, meta_warnings = _resolve_from_metadata(skill_dir)
316
+ warnings.extend(f"{skill_name}: {w}" for w in meta_warnings)
317
+
318
+ if exports is not None:
319
+ source = SOURCE_METADATA
320
+ else:
321
+ # 2. references/
322
+ ref_exports = _resolve_from_references(skill_dir)
323
+ if ref_exports:
324
+ exports = ref_exports
325
+ source = SOURCE_REFERENCES
326
+ else:
327
+ # 3. SKILL.md prose
328
+ sk_exports = _resolve_from_skill_md(skill_dir)
329
+ if sk_exports:
330
+ exports = sk_exports
331
+ source = SOURCE_SKILL_MD
332
+ else:
333
+ # 4. None found
334
+ exports = []
335
+ source = SOURCE_UNKNOWN
336
+ warnings.append(f"{skill_name}: no exports found via any resolution path")
337
+
338
+ entry = {
339
+ "name": skill_name,
340
+ "path": skill_name, # forward-slash relative path under skills-root
341
+ "exports": exports,
342
+ "exports_source": source,
343
+ "confidence": _CONFIDENCE_BY_SOURCE[source],
344
+ "metadata_hash": metadata_hash,
345
+ }
346
+ return entry, composes, warnings
347
+
348
+
349
+ # --------------------------------------------------------------------------
350
+ # Cycle detection (composes graph)
351
+ # --------------------------------------------------------------------------
352
+
353
+
354
+ def detect_cycles(graph: dict[str, list[str]]) -> list[str]:
355
+ """Find nodes that participate in a `composes` cycle.
356
+
357
+ Returns a sorted list of nodes from which a cycle is reachable.
358
+ Each cycle is represented once by the lexicographically-smallest
359
+ node on it — keeps output deterministic across runs without
360
+ requiring callers to interpret a path.
361
+
362
+ Algorithm: DFS with WHITE/GRAY/BLACK colouring. A back-edge to a
363
+ GRAY ancestor identifies a cycle; we record the ancestor.
364
+ """
365
+ WHITE, GRAY, BLACK = 0, 1, 2
366
+ colour: dict[str, int] = {node: WHITE for node in graph}
367
+ on_path: list[str] = []
368
+ cycle_nodes: set[str] = set()
369
+
370
+ def dfs(node: str) -> None:
371
+ colour[node] = GRAY
372
+ on_path.append(node)
373
+ for nbr in graph.get(node, []):
374
+ if nbr not in graph:
375
+ # composes-target not in this skills-root; treat as
376
+ # external, no cycle contribution.
377
+ continue
378
+ c = colour[nbr]
379
+ if c == WHITE:
380
+ dfs(nbr)
381
+ elif c == GRAY:
382
+ # Back-edge → cycle. Record the smallest node on the
383
+ # cycle path from nbr onward.
384
+ idx = on_path.index(nbr)
385
+ cycle_nodes.add(min(on_path[idx:]))
386
+ on_path.pop()
387
+ colour[node] = BLACK
388
+
389
+ for node in sorted(graph):
390
+ if colour[node] == WHITE:
391
+ dfs(node)
392
+
393
+ return sorted(cycle_nodes)
394
+
395
+
396
+ # --------------------------------------------------------------------------
397
+ # Enumeration entry point
398
+ # --------------------------------------------------------------------------
399
+
400
+
401
+ def enumerate_stack_skills(skills_root: Path) -> dict:
402
+ """Walk `skills_root`, build inventory, detect cycles, return result."""
403
+ result: dict = {"skills": [], "cycles": [], "warnings": []}
404
+ compose_graph: dict[str, list[str]] = {}
405
+
406
+ try:
407
+ children = sorted(skills_root.iterdir())
408
+ except OSError as exc:
409
+ # Shouldn't reach here if CLI validated skills_root.is_dir(),
410
+ # but handle defensively for direct API callers.
411
+ result["warnings"].append(f"failed to read skills root: {exc}")
412
+ return result
413
+
414
+ for child in children:
415
+ name = child.name
416
+ if name.startswith("."):
417
+ continue
418
+
419
+ # Symlink fallback — resolve and verify target is a directory we
420
+ # can read. If anything fails, warn and skip the entry.
421
+ if child.is_symlink():
422
+ try:
423
+ target = child.resolve(strict=True)
424
+ except (FileNotFoundError, RuntimeError, OSError) as exc:
425
+ result["warnings"].append(
426
+ f"{name}: symlink target unreadable ({exc})"
427
+ )
428
+ continue
429
+ if not target.is_dir():
430
+ result["warnings"].append(
431
+ f"{name}: symlink target is not a directory"
432
+ )
433
+ continue
434
+ skill_dir = target
435
+ else:
436
+ if not child.is_dir():
437
+ continue
438
+ skill_dir = child
439
+
440
+ # Require SKILL.md to consider this a skill package. Subdirs
441
+ # without SKILL.md (e.g. `shared/`, `knowledge/`) are skipped
442
+ # silently — they're not skill packages by definition.
443
+ if not (skill_dir / "SKILL.md").is_file():
444
+ continue
445
+
446
+ try:
447
+ entry, composes, skill_warnings = resolve_skill(skill_dir, name)
448
+ except Exception as exc: # noqa: BLE001 — per-skill failures are warnings, not fatal
449
+ result["warnings"].append(f"{name}: enumeration failed: {exc}")
450
+ continue
451
+
452
+ result["skills"].append(entry)
453
+ result["warnings"].extend(skill_warnings)
454
+ compose_graph[name] = composes
455
+
456
+ result["cycles"] = detect_cycles(compose_graph)
457
+ for cycle_node in result["cycles"]:
458
+ result["warnings"].append(
459
+ f"{cycle_node}: composes cycle detected"
460
+ )
461
+ return result
462
+
463
+
464
+ # --------------------------------------------------------------------------
465
+ # CLI
466
+ # --------------------------------------------------------------------------
467
+
468
+
469
+ def _cmd_enumerate(args: argparse.Namespace) -> int:
470
+ skills_root = Path(args.skills_root)
471
+ if not skills_root.is_dir():
472
+ print(
473
+ f"error: skills root not a directory: {skills_root}",
474
+ file=sys.stderr,
475
+ )
476
+ return 1
477
+ result = enumerate_stack_skills(skills_root)
478
+ json.dump(result, sys.stdout, indent=2)
479
+ sys.stdout.write("\n")
480
+ return 0
481
+
482
+
483
+ def _build_parser() -> argparse.ArgumentParser:
484
+ parser = argparse.ArgumentParser(
485
+ prog="skf-enumerate-stack-skills",
486
+ description=(
487
+ "Enumerate skill packages under a skills root and resolve "
488
+ "each package's exports via the metadata.json → references/ "
489
+ "→ SKILL.md cascade."
490
+ ),
491
+ )
492
+ sub = parser.add_subparsers(dest="cmd", required=True)
493
+
494
+ p_enum = sub.add_parser(
495
+ "enumerate",
496
+ help="walk skills-root and emit JSON inventory",
497
+ )
498
+ p_enum.add_argument(
499
+ "skills_root",
500
+ help="directory containing skill packages (each subdir has SKILL.md)",
501
+ )
502
+ p_enum.set_defaults(func=_cmd_enumerate)
503
+
504
+ return parser
505
+
506
+
507
+ def main(argv: list[str] | None = None) -> int:
508
+ parser = _build_parser()
509
+ args = parser.parse_args(argv)
510
+ return args.func(args)
511
+
512
+
513
+ if __name__ == "__main__":
514
+ raise SystemExit(main())