bmad-module-skill-forge 2.0.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 (235) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/package.json +2 -2
  5. package/src/knowledge/skill-lifecycle.md +1 -1
  6. package/src/module-help.csv +2 -2
  7. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  8. package/src/shared/scripts/skf-count-tokens.py +222 -0
  9. package/src/shared/scripts/skf-detect-docs.py +232 -1
  10. package/src/shared/scripts/skf-detect-language.py +77 -0
  11. package/src/shared/scripts/skf-detect-tools.py +105 -2
  12. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  13. package/src/shared/scripts/skf-find-cycles.py +246 -0
  14. package/src/shared/scripts/skf-hash-content.py +487 -2
  15. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  16. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  17. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  18. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  19. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  20. package/src/shared/scripts/skf-shard-body.py +461 -0
  21. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  22. package/src/shared/scripts/skf-structural-diff.py +307 -67
  23. package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
  24. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  25. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  26. package/src/shared/scripts/skf-validate-output.py +443 -10
  27. package/src/shared/scripts/skf-validate-pins.py +1 -1
  28. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  30. package/src/skf-analyze-source/SKILL.md +17 -32
  31. package/src/skf-analyze-source/customize.toml +2 -0
  32. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  33. package/src/skf-analyze-source/references/continue.md +8 -13
  34. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  35. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  36. package/src/skf-analyze-source/references/health-check.md +1 -2
  37. package/src/skf-analyze-source/references/identify-units.md +25 -36
  38. package/src/skf-analyze-source/references/init.md +12 -25
  39. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  40. package/src/skf-analyze-source/references/recommend.md +4 -14
  41. package/src/skf-analyze-source/references/scan-project.md +3 -11
  42. package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
  43. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  44. package/src/skf-audit-skill/SKILL.md +9 -6
  45. package/src/skf-audit-skill/customize.toml +2 -0
  46. package/src/skf-audit-skill/references/init.md +58 -32
  47. package/src/skf-audit-skill/references/re-index.md +15 -34
  48. package/src/skf-audit-skill/references/report.md +11 -58
  49. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  50. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  51. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  52. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  53. package/src/skf-brief-skill/SKILL.md +7 -36
  54. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  55. package/src/skf-brief-skill/customize.toml +14 -0
  56. package/src/skf-brief-skill/references/analyze-target.md +6 -14
  57. package/src/skf-brief-skill/references/confirm-brief.md +2 -14
  58. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  59. package/src/skf-brief-skill/references/gather-intent.md +19 -22
  60. package/src/skf-brief-skill/references/headless-args.md +1 -1
  61. package/src/skf-brief-skill/references/health-check.md +1 -22
  62. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  63. package/src/skf-brief-skill/references/scope-definition.md +4 -14
  64. package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
  65. package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
  66. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  67. package/src/skf-brief-skill/references/write-brief.md +17 -10
  68. package/src/skf-campaign/SKILL.md +15 -84
  69. package/src/skf-campaign/customize.toml +2 -0
  70. package/src/skf-campaign/manifest.yaml +4 -0
  71. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  72. package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
  73. package/src/skf-campaign/references/step-01-setup.md +4 -5
  74. package/src/skf-campaign/references/step-02-strategy.md +8 -3
  75. package/src/skf-campaign/references/step-03-pins.md +1 -1
  76. package/src/skf-campaign/references/step-04-provenance.md +1 -1
  77. package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
  78. package/src/skf-campaign/references/step-06-batch.md +7 -6
  79. package/src/skf-campaign/references/step-11-maintenance.md +2 -2
  80. package/src/skf-campaign/references/step-resume.md +9 -8
  81. package/src/skf-campaign/scripts/campaign-deps.py +10 -1
  82. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  83. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  84. package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
  85. package/src/skf-create-skill/SKILL.md +14 -3
  86. package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
  87. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  88. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  89. package/src/skf-create-skill/customize.toml +13 -0
  90. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  91. package/src/skf-create-skill/references/compile.md +34 -47
  92. package/src/skf-create-skill/references/component-extraction.md +8 -35
  93. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  94. package/src/skf-create-skill/references/enrich.md +4 -17
  95. package/src/skf-create-skill/references/extract.md +14 -29
  96. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  97. package/src/skf-create-skill/references/extraction-patterns.md +7 -7
  98. package/src/skf-create-skill/references/generate-artifacts.md +9 -23
  99. package/src/skf-create-skill/references/health-check.md +1 -1
  100. package/src/skf-create-skill/references/load-brief.md +16 -18
  101. package/src/skf-create-skill/references/report.md +12 -15
  102. package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
  103. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  104. package/src/skf-create-skill/references/step-doc-rot.md +30 -12
  105. package/src/skf-create-skill/references/step-doc-sources.md +2 -2
  106. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  107. package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
  108. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  109. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  110. package/src/skf-create-skill/references/validate.md +59 -28
  111. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  112. package/src/skf-create-stack-skill/SKILL.md +15 -9
  113. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  114. package/src/skf-create-stack-skill/customize.toml +10 -0
  115. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  116. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  117. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  118. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  119. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  120. package/src/skf-create-stack-skill/references/init.md +9 -81
  121. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  122. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  123. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  124. package/src/skf-create-stack-skill/references/report.md +27 -62
  125. package/src/skf-create-stack-skill/references/validate.md +35 -31
  126. package/src/skf-drop-skill/SKILL.md +20 -23
  127. package/src/skf-drop-skill/customize.toml +10 -0
  128. package/src/skf-drop-skill/references/execute.md +51 -31
  129. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  130. package/src/skf-drop-skill/references/health-check.md +1 -13
  131. package/src/skf-drop-skill/references/report.md +42 -10
  132. package/src/skf-drop-skill/references/select.md +88 -48
  133. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  134. package/src/skf-export-skill/SKILL.md +17 -14
  135. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  136. package/src/skf-export-skill/customize.toml +10 -6
  137. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  138. package/src/skf-export-skill/references/load-skill.md +44 -42
  139. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  140. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  141. package/src/skf-export-skill/references/package.md +27 -33
  142. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  143. package/src/skf-export-skill/references/result-envelope.md +28 -0
  144. package/src/skf-export-skill/references/summary.md +11 -5
  145. package/src/skf-export-skill/references/token-report.md +42 -24
  146. package/src/skf-export-skill/references/update-context.md +32 -33
  147. package/src/skf-forger/SKILL.md +27 -39
  148. package/src/skf-forger/references/pipeline-mode.md +34 -0
  149. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  150. package/src/skf-quick-skill/SKILL.md +10 -60
  151. package/src/skf-quick-skill/customize.toml +9 -0
  152. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  153. package/src/skf-quick-skill/references/compile.md +15 -52
  154. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  155. package/src/skf-quick-skill/references/finalize.md +12 -6
  156. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  157. package/src/skf-quick-skill/references/health-check.md +3 -4
  158. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  159. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  160. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  161. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  162. package/src/skf-refine-architecture/SKILL.md +14 -7
  163. package/src/skf-refine-architecture/customize.toml +9 -0
  164. package/src/skf-refine-architecture/references/compile.md +9 -7
  165. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  166. package/src/skf-refine-architecture/references/health-check.md +1 -1
  167. package/src/skf-refine-architecture/references/improvements.md +9 -25
  168. package/src/skf-refine-architecture/references/init.md +9 -16
  169. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  170. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  171. package/src/skf-refine-architecture/references/report.md +13 -10
  172. package/src/skf-rename-skill/SKILL.md +11 -21
  173. package/src/skf-rename-skill/customize.toml +10 -0
  174. package/src/skf-rename-skill/references/execute.md +107 -78
  175. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  176. package/src/skf-rename-skill/references/report.md +16 -8
  177. package/src/skf-rename-skill/references/select.md +69 -42
  178. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  179. package/src/skf-setup/SKILL.md +20 -12
  180. package/src/skf-setup/customize.toml +10 -0
  181. package/src/skf-setup/references/auto-index.md +9 -10
  182. package/src/skf-setup/references/ccc-index.md +14 -15
  183. package/src/skf-setup/references/detect-and-tier.md +6 -8
  184. package/src/skf-setup/references/report.md +22 -31
  185. package/src/skf-setup/references/tier-rules.md +2 -32
  186. package/src/skf-setup/references/write-config.md +9 -10
  187. package/src/skf-test-skill/SKILL.md +10 -8
  188. package/src/skf-test-skill/customize.toml +2 -0
  189. package/src/skf-test-skill/references/coherence-check.md +19 -31
  190. package/src/skf-test-skill/references/coverage-check.md +109 -75
  191. package/src/skf-test-skill/references/detect-mode.md +10 -41
  192. package/src/skf-test-skill/references/external-validators.md +11 -19
  193. package/src/skf-test-skill/references/init.md +38 -20
  194. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  195. package/src/skf-test-skill/references/report.md +60 -40
  196. package/src/skf-test-skill/references/score.md +32 -64
  197. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  198. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  199. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  200. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  201. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  202. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  203. package/src/skf-test-skill/scripts/compute-score.py +131 -1
  204. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  205. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  206. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  207. package/src/skf-update-skill/SKILL.md +17 -5
  208. package/src/skf-update-skill/customize.toml +14 -0
  209. package/src/skf-update-skill/references/detect-changes.md +19 -37
  210. package/src/skf-update-skill/references/health-check.md +3 -9
  211. package/src/skf-update-skill/references/init.md +39 -32
  212. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  213. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  214. package/src/skf-update-skill/references/merge.md +13 -57
  215. package/src/skf-update-skill/references/re-extract.md +12 -24
  216. package/src/skf-update-skill/references/report.md +14 -7
  217. package/src/skf-update-skill/references/validate.md +32 -30
  218. package/src/skf-update-skill/references/write.md +86 -51
  219. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  220. package/src/skf-verify-stack/SKILL.md +11 -21
  221. package/src/skf-verify-stack/customize.toml +12 -3
  222. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  223. package/src/skf-verify-stack/references/coverage.md +21 -10
  224. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  225. package/src/skf-verify-stack/references/health-check.md +1 -1
  226. package/src/skf-verify-stack/references/init.md +20 -25
  227. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  228. package/src/skf-verify-stack/references/integrations.md +44 -49
  229. package/src/skf-verify-stack/references/report.md +28 -34
  230. package/src/skf-verify-stack/references/requirements.md +3 -3
  231. package/src/skf-verify-stack/references/synthesize.md +36 -39
  232. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  233. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  234. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  235. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -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())