bmad-module-skill-forge 1.9.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -22,7 +22,7 @@ SKILL.md, then extract usage patterns per skill (the part that does
22
22
  benefit from LLM judgment).
23
23
 
24
24
  Subcommand:
25
- enumerate <skills-root>
25
+ enumerate <skills-root> [--pairs] [--reliability]
26
26
  Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
27
27
  describing every subdirectory of <skills-root> that resolves to a
28
28
  skill package. Both layouts from knowledge/version-paths.md are
@@ -84,18 +84,47 @@ Symlink handling:
84
84
  Per-skill errors (malformed metadata.json, OSError, etc.) are captured
85
85
  as warnings on the top-level result; they do not exit the process.
86
86
 
87
+ Optional derived output (additive flags — off by default, so existing
88
+ consumers that read only skills/cycles/warnings are unaffected):
89
+
90
+ --pairs
91
+ Attach the complete set of unique library pairs derived from the
92
+ inventory: `pairs = [{"library_a": a, "library_b": b}, ...]` over
93
+ `itertools.combinations` of the sorted, deduplicated skill names,
94
+ plus `pair_count == N*(N-1)/2`. This is the deterministic
95
+ combinatorics the refine-architecture gap-analysis prompt used to
96
+ do in-context (where a dropped pair is a silently missed
97
+ integration gap). Sorting the names first makes ordering
98
+ independent of directory-scan order and byte-stable across runs.
99
+
100
+ --reliability
101
+ Attach the inventory reliability verdict computed from the counts
102
+ the script already owns:
103
+ skill_count — len(skills)
104
+ warning_count — len(warnings)
105
+ unreliable_ratio — warning_count / (skill_count + warning_count),
106
+ or 0.0 when the inventory is empty
107
+ inventory_reliable — unreliable_ratio <= 0.20 (the RELIABILITY
108
+ THRESHOLD lives here, in one unit-tested place,
109
+ so consuming prompts read a boolean instead of
110
+ re-deriving a ratio + threshold comparison).
111
+ The raw counts are emitted alongside the boolean so a caller's halt
112
+ message can still render "{warning_count}/{skill_count+warning_count}
113
+ skills returned malformed metadata".
114
+
87
115
  Exit codes:
88
116
  0 enumeration succeeded (including zero skills found)
89
117
  1 user error (bad skills-root path)
90
118
 
91
119
  CLI:
92
- uv run skf-enumerate-stack-skills.py enumerate <skills-root>
120
+ uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability]
93
121
  """
94
122
 
95
123
  from __future__ import annotations
96
124
 
97
125
  import argparse
98
126
  import hashlib
127
+ import itertools
99
128
  import json
100
129
  import re
101
130
  import sys
@@ -112,6 +141,13 @@ SOURCE_REFERENCES = "references"
112
141
  SOURCE_SKILL_MD = "skill-md"
113
142
  SOURCE_UNKNOWN = "unknown"
114
143
 
144
+ # Inventory reliability policy (single source of truth). If the fraction of
145
+ # skip/malformed warnings exceeds this threshold, the inventory is deemed
146
+ # unreliable. The gate is `ratio <= THRESHOLD` (i.e. a ratio of exactly 0.20
147
+ # is still reliable) so a single malformed skill in a small 3-5 skill
148
+ # inventory does not trip the halt.
149
+ RELIABILITY_THRESHOLD = 0.20
150
+
115
151
  _CONFIDENCE_BY_SOURCE = {
116
152
  SOURCE_METADATA: "T1",
117
153
  SOURCE_REFERENCES: "T2",
@@ -552,6 +588,53 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
552
588
  return result
553
589
 
554
590
 
591
+ # --------------------------------------------------------------------------
592
+ # Derived output: unique library pairs (--pairs)
593
+ # --------------------------------------------------------------------------
594
+
595
+
596
+ def compute_pairs(skills: list[dict]) -> list[dict]:
597
+ """Deterministic unique library pairs from an inventory's skills[].
598
+
599
+ Returns `[{"library_a": a, "library_b": b}, ...]` over every
600
+ combination of the sorted, deduplicated skill names — exactly
601
+ N*(N-1)/2 entries for N distinct names. Sorting first makes the order
602
+ independent of directory-scan order and byte-stable across runs; the
603
+ dedup guards against a pathological repeated name yielding a duplicate
604
+ pair. Never drops or duplicates a pair, unlike in-context enumeration
605
+ at larger N.
606
+ """
607
+ names = sorted({e["name"] for e in skills})
608
+ return [
609
+ {"library_a": a, "library_b": b}
610
+ for a, b in itertools.combinations(names, 2)
611
+ ]
612
+
613
+
614
+ # --------------------------------------------------------------------------
615
+ # Derived output: inventory reliability verdict (--reliability)
616
+ # --------------------------------------------------------------------------
617
+
618
+
619
+ def compute_reliability(skill_count: int, warning_count: int) -> dict:
620
+ """Reliability verdict from the inventory's own counts.
621
+
622
+ `unreliable_ratio` = warning_count / (skill_count + warning_count),
623
+ or 0.0 for an empty inventory (no ZeroDivisionError). The inventory is
624
+ reliable when that ratio is <= RELIABILITY_THRESHOLD (boundary
625
+ inclusive). Emits the raw counts too so a caller's halt message can
626
+ render "{warning_count}/{skill_count+warning_count}".
627
+ """
628
+ total = skill_count + warning_count
629
+ unreliable_ratio = (warning_count / total) if total else 0.0
630
+ return {
631
+ "inventory_reliable": unreliable_ratio <= RELIABILITY_THRESHOLD,
632
+ "unreliable_ratio": unreliable_ratio,
633
+ "skill_count": skill_count,
634
+ "warning_count": warning_count,
635
+ }
636
+
637
+
555
638
  # --------------------------------------------------------------------------
556
639
  # CLI
557
640
  # --------------------------------------------------------------------------
@@ -566,6 +649,15 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
566
649
  )
567
650
  return 1
568
651
  result = enumerate_stack_skills(skills_root)
652
+ # Additive derived output — attached only when the flag is set, so the
653
+ # default shape stays {skills, cycles, warnings} for existing consumers.
654
+ if args.pairs:
655
+ result["pairs"] = compute_pairs(result["skills"])
656
+ result["pair_count"] = len(result["pairs"])
657
+ if args.reliability:
658
+ result.update(
659
+ compute_reliability(len(result["skills"]), len(result["warnings"]))
660
+ )
569
661
  json.dump(result, sys.stdout, indent=2)
570
662
  sys.stdout.write("\n")
571
663
  return 0
@@ -590,6 +682,23 @@ def _build_parser() -> argparse.ArgumentParser:
590
682
  "skills_root",
591
683
  help="directory containing skill packages (each subdir has SKILL.md)",
592
684
  )
685
+ p_enum.add_argument(
686
+ "--pairs",
687
+ action="store_true",
688
+ help=(
689
+ "additionally emit `pairs[]` (unique {library_a, library_b} "
690
+ "combinations over the sorted skill names) and `pair_count`"
691
+ ),
692
+ )
693
+ p_enum.add_argument(
694
+ "--reliability",
695
+ action="store_true",
696
+ help=(
697
+ "additionally emit the reliability verdict "
698
+ "(`inventory_reliable`, `unreliable_ratio`, `skill_count`, "
699
+ f"`warning_count`; threshold {RELIABILITY_THRESHOLD})"
700
+ ),
701
+ )
593
702
  p_enum.set_defaults(func=_cmd_enumerate)
594
703
 
595
704
  return parser
@@ -261,6 +261,29 @@ def parse_settings_gradle(content: str) -> list[str]:
261
261
  return modules
262
262
 
263
263
 
264
+ def parse_package_swift(content: str) -> dict:
265
+ """Best-effort parse of a SwiftPM Package.swift manifest.
266
+
267
+ SwiftPM has no version field in the manifest (versions come from git tags),
268
+ so `version` is always None; the brief falls back to target_version / default.
269
+ """
270
+ name_m = re.search(r"\bPackage\s*\(\s*name:\s*['\"]([^'\"]+)['\"]", content, re.DOTALL)
271
+ deps: list[str] = []
272
+ for m in re.finditer(r"\.package\s*\(\s*url:\s*['\"]([^'\"]+)['\"]", content):
273
+ seg = m.group(1).rstrip("/").rsplit("/", 1)[-1]
274
+ if seg.endswith(".git"):
275
+ seg = seg[: -len(".git")]
276
+ if seg:
277
+ deps.append(seg)
278
+ return {
279
+ "name": name_m.group(1).strip() if name_m else None,
280
+ "version": None,
281
+ "description": None,
282
+ "dependencies": deps,
283
+ "modules": [],
284
+ }
285
+
286
+
264
287
  # --------------------------------------------------------------------------
265
288
  # Export scanners
266
289
  # --------------------------------------------------------------------------
@@ -388,6 +411,35 @@ def scan_exports_kotlin(content: str, source_file: str) -> list[dict]:
388
411
  return out
389
412
 
390
413
 
414
+ # Swift declarations are public only when explicitly marked `public`/`open`
415
+ # (the default is internal). The lazy `[^\n]*?` skips intervening modifiers
416
+ # (final/static/class-method/@attributes) between the access keyword and the
417
+ # declaration keyword.
418
+ _SWIFT_DECL_RE = re.compile(
419
+ r"^\s*(?:public|open)\b[^\n]*?\b"
420
+ r"(func|class|struct|enum|protocol|actor|typealias|var|let)\s+([A-Za-z_]\w*)",
421
+ re.MULTILINE,
422
+ )
423
+ _SWIFT_DECL_KEYWORDS = {
424
+ "func", "class", "struct", "enum", "protocol", "actor", "typealias", "var", "let",
425
+ }
426
+
427
+
428
+ def scan_exports_swift(content: str, source_file: str) -> list[dict]:
429
+ """Swift defaults to internal — emit only public/open declarations."""
430
+ out: list[dict] = []
431
+ seen: set[str] = set()
432
+ for m in _SWIFT_DECL_RE.finditer(content):
433
+ name = m.group(2)
434
+ # Guard the rare `public class func foo` case where the lazy skip stops
435
+ # on the `class` modifier and captures the next keyword as the name.
436
+ if name in _SWIFT_DECL_KEYWORDS or name in seen:
437
+ continue
438
+ seen.add(name)
439
+ out.append({"name": name, "type": m.group(1), "source_file": source_file})
440
+ return out
441
+
442
+
391
443
  # --------------------------------------------------------------------------
392
444
  # Orchestrator
393
445
  # --------------------------------------------------------------------------
@@ -405,6 +457,7 @@ LANGUAGE_DISPATCH: dict[str, tuple[ManifestParser, ExportScanner]] = {
405
457
  "go": (parse_go_mod, scan_exports_go),
406
458
  "java": (parse_pom_xml, scan_exports_java),
407
459
  "kotlin": (parse_gradle, scan_exports_kotlin),
460
+ "swift": (parse_package_swift, scan_exports_swift),
408
461
  }
409
462
 
410
463
 
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = []
5
+ # ///
6
+ """SKF Find Cycles — enumerate the simple directed cycles in a pair graph.
7
+
8
+ `skf-verify-stack`'s `integrations.md` §4 (Cross-Reference Each Integration
9
+ Pair → Cycle detection) asks the LLM to build a directed pair graph — an edge
10
+ `A → B` exists when skill A literally cites skill B via Check 4 — and then to
11
+ "run cycle detection (DFS with visited + recursion stack)", appending one
12
+ synthetic `Risky` verdict row per circular integration dependency found.
13
+
14
+ Deciding *which* edges exist is a semantic judgment (Check-4 literal-citation
15
+ analysis) and stays in the prompt. But once the edge set is fixed, the set of
16
+ directed cycles is fully determined — a graph traversal with exactly one
17
+ correct answer per edge set. In-prompt DFS silently misses real multi-hop
18
+ cycles or invents spurious ones as the pair count grows; this helper makes the
19
+ traversal deterministic and unit-testable. The prompt hands over the edges as
20
+ JSON and appends the synthetic rows from `cycles[]` — no traversal by hand.
21
+
22
+ Subcommand:
23
+ find --edges <json-file-or-'-'>
24
+ --edges path to the edges JSON, or '-' to read from stdin.
25
+
26
+ Input shape (the Check-4 citation edges, one [from, to] pair each):
27
+ {"edges": [["A", "B"], ["B", "C"], ["C", "A"], ...]}
28
+
29
+ Emit JSON:
30
+ {
31
+ "cycles": [["A", "B", "C", "A"], ...],
32
+ "cycle_count": N
33
+ }
34
+
35
+ Each cycle is a closed node path: the first node is repeated at the end
36
+ (`A → B → C → A` renders as ["A", "B", "C", "A"]). Every simple directed
37
+ cycle is enumerated exactly once — de-duplicated across its rotations by
38
+ canonicalising to the lexicographically-minimal rotation. `cycles[]` is
39
+ sorted by length ASC then lexicographically for stable, reproducible
40
+ ordering. A self-loop edge (["A", "A"]) is a 1-node cycle ["A", "A"].
41
+
42
+ CLI examples:
43
+ uv run skf-find-cycles.py find --edges edges.json
44
+ echo '{"edges": [["A","B"],["B","A"]]}' | uv run skf-find-cycles.py find --edges -
45
+
46
+ Exit codes:
47
+ 0 — operation succeeded (including: no cycles / empty edge set → empty list)
48
+ 2 — malformed input (bad JSON, wrong shape, non-string node) or internal
49
+ error
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import json
56
+ import sys
57
+ from pathlib import Path
58
+
59
+
60
+ class InputError(Exception):
61
+ """A malformed-input error → exit code 2."""
62
+
63
+
64
+ # --------------------------------------------------------------------------
65
+ # Input validation
66
+ # --------------------------------------------------------------------------
67
+
68
+
69
+ def parse_edges(payload: object) -> list[tuple[str, str]]:
70
+ """Validate and normalise the edges payload.
71
+
72
+ Accepts `{"edges": [[from, to], ...]}`; each edge must be a two-element
73
+ array of non-empty strings. Raises InputError on any structural defect.
74
+ Returns a list of (from, to) tuples (order preserved; duplicates kept —
75
+ they collapse during adjacency construction).
76
+ """
77
+ if not isinstance(payload, dict):
78
+ raise InputError(
79
+ f"input must be a JSON object with an `edges` key; "
80
+ f"got {type(payload).__name__}"
81
+ )
82
+ edges = payload.get("edges")
83
+ if not isinstance(edges, list):
84
+ raise InputError("input `edges` must be a JSON array of [from, to] pairs")
85
+ normalized: list[tuple[str, str]] = []
86
+ for idx, edge in enumerate(edges):
87
+ if not isinstance(edge, list) or len(edge) != 2:
88
+ raise InputError(
89
+ f"edges[{idx}] must be a two-element [from, to] array; got {edge!r}"
90
+ )
91
+ src, dst = edge
92
+ if not isinstance(src, str) or not src:
93
+ raise InputError(f"edges[{idx}][0] must be a non-empty string; got {src!r}")
94
+ if not isinstance(dst, str) or not dst:
95
+ raise InputError(f"edges[{idx}][1] must be a non-empty string; got {dst!r}")
96
+ normalized.append((src, dst))
97
+ return normalized
98
+
99
+
100
+ # --------------------------------------------------------------------------
101
+ # Cycle enumeration
102
+ # --------------------------------------------------------------------------
103
+
104
+
105
+ def _canonicalize(cycle_nodes: list[str]) -> tuple[str, ...]:
106
+ """Return the lexicographically-minimal rotation of a cycle's node list.
107
+
108
+ `cycle_nodes` is the cycle without the closing repeat (e.g. ["B","C","A"]
109
+ for B → C → A → B). All rotations describe the same directed cycle; the
110
+ minimal rotation is the stable de-duplication key ((A, B, C) here).
111
+ """
112
+ n = len(cycle_nodes)
113
+ rotations = [tuple(cycle_nodes[i:] + cycle_nodes[:i]) for i in range(n)]
114
+ return min(rotations)
115
+
116
+
117
+ def find_cycles(edges: list[tuple[str, str]]) -> dict:
118
+ """Enumerate every simple directed cycle in the edge set.
119
+
120
+ Deterministic: same edge set → byte-identical output. Runs a DFS from each
121
+ node (in sorted order) tracking the current recursion path; whenever the
122
+ walk returns to its start node a cycle is recorded, canonicalised to its
123
+ minimal rotation, and de-duplicated. Graphs here are small pair graphs, so
124
+ the exhaustive enumeration is inexpensive.
125
+ """
126
+ # Build sorted, de-duplicated adjacency for deterministic traversal.
127
+ adj: dict[str, list[str]] = {}
128
+ nodes: set[str] = set()
129
+ for src, dst in edges:
130
+ adj.setdefault(src, [])
131
+ if dst not in adj[src]:
132
+ adj[src].append(dst)
133
+ nodes.add(src)
134
+ nodes.add(dst)
135
+ for src in adj:
136
+ adj[src].sort()
137
+
138
+ seen: set[tuple[str, ...]] = set()
139
+
140
+ def dfs(start: str, current: str, path: list[str], on_stack: set[str]) -> None:
141
+ for nxt in adj.get(current, ()):
142
+ if nxt == start:
143
+ canon = _canonicalize(path)
144
+ seen.add(canon)
145
+ elif nxt not in on_stack:
146
+ on_stack.add(nxt)
147
+ path.append(nxt)
148
+ dfs(start, nxt, path, on_stack)
149
+ path.pop()
150
+ on_stack.discard(nxt)
151
+
152
+ for start in sorted(nodes):
153
+ dfs(start, start, [start], {start})
154
+
155
+ # Stable output order: by cycle length, then lexicographically.
156
+ ordered = sorted(seen, key=lambda c: (len(c), c))
157
+ cycles = [list(c) + [c[0]] for c in ordered]
158
+ return {"cycles": cycles, "cycle_count": len(cycles)}
159
+
160
+
161
+ # --------------------------------------------------------------------------
162
+ # CLI
163
+ # --------------------------------------------------------------------------
164
+
165
+
166
+ def _read_source(source: str) -> str:
167
+ """Read text from a file path or stdin (if source == '-')."""
168
+ if source == "-":
169
+ try:
170
+ return sys.stdin.read()
171
+ except OSError as exc:
172
+ raise InputError(f"failed to read --edges from stdin: {exc}") from exc
173
+ path = Path(source)
174
+ if not path.is_file():
175
+ raise InputError(f"--edges file not found: {path}")
176
+ try:
177
+ return path.read_text(encoding="utf-8")
178
+ except OSError as exc:
179
+ raise InputError(f"failed to read --edges file {path}: {exc}") from exc
180
+
181
+
182
+ def _cmd_find(args: argparse.Namespace) -> int:
183
+ text = _read_source(args.edges)
184
+ try:
185
+ payload = json.loads(text)
186
+ except json.JSONDecodeError as exc:
187
+ raise InputError(f"malformed JSON in --edges input: {exc}") from exc
188
+ edges = parse_edges(payload)
189
+ result = find_cycles(edges)
190
+ if args.verbose:
191
+ print(
192
+ f"analyzed {len(edges)} edge(s); found {result['cycle_count']} cycle(s)",
193
+ file=sys.stderr,
194
+ )
195
+ json.dump(result, sys.stdout, indent=2)
196
+ sys.stdout.write("\n")
197
+ return 0
198
+
199
+
200
+ def _build_parser() -> argparse.ArgumentParser:
201
+ parser = argparse.ArgumentParser(
202
+ prog="skf-find-cycles",
203
+ description=(
204
+ "Enumerate the simple directed cycles in an integration pair graph "
205
+ "(skf-verify-stack integrations.md §4 cycle detection)."
206
+ ),
207
+ )
208
+ sub = parser.add_subparsers(dest="cmd", required=True)
209
+
210
+ p_find = sub.add_parser(
211
+ "find",
212
+ help="emit the de-duplicated simple directed cycles as JSON",
213
+ )
214
+ p_find.add_argument(
215
+ "--edges",
216
+ required=True,
217
+ help=(
218
+ "path to the edges JSON, or '-' for stdin. "
219
+ 'Shape: {"edges": [["<from>", "<to>"], ...]}'
220
+ ),
221
+ )
222
+ p_find.add_argument(
223
+ "--verbose",
224
+ action="store_true",
225
+ help="print a one-line summary to stderr",
226
+ )
227
+ p_find.set_defaults(func=_cmd_find)
228
+
229
+ return parser
230
+
231
+
232
+ def main(argv: list[str] | None = None) -> int:
233
+ parser = _build_parser()
234
+ args = parser.parse_args(argv)
235
+ try:
236
+ return args.func(args)
237
+ except InputError as exc:
238
+ print(f"error: {exc}", file=sys.stderr)
239
+ return 2
240
+ except Exception as exc: # noqa: BLE001 — last-resort guard → exit 2
241
+ print(f"internal error: {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+
245
+ if __name__ == "__main__":
246
+ raise SystemExit(main())