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
@@ -0,0 +1,244 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """Campaign Deps — dependency computation and enforcement for campaign skills.
6
+
7
+ Two modes:
8
+ --compute: topological sort of all skills from depends_on edges
9
+ --check: verify a single skill's dependencies are satisfied
10
+
11
+ CLI:
12
+ uv run campaign-deps.py --compute --state-file <path>
13
+ uv run campaign-deps.py --check --state-file <path> --skill <name>
14
+ uv run campaign-deps.py --check --state-file <path> --skill <name> --force
15
+
16
+ Output (JSON on stdout):
17
+ --compute:
18
+ {"execution_order": [...], "circular_deps_detected": bool,
19
+ "cycle_participants": [...] | null, "tier_counts": {"A": int, "B": int}}
20
+
21
+ --check:
22
+ {"skill": "name", "ready": bool, "unmet_deps": [...], "forced": bool}
23
+
24
+ Exit codes:
25
+ 0 success / ready / force-override
26
+ 1 circular deps / unmet deps / dangling reference
27
+ 2 error (missing file, bad YAML)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import sys
35
+ import heapq
36
+ from pathlib import Path
37
+ from typing import Any, Dict, List, Optional
38
+
39
+ import yaml
40
+
41
+
42
+ def _emit_error(message: str, code: str) -> None:
43
+ json.dump({"error": message, "code": code}, sys.stderr)
44
+ sys.stderr.write("\n")
45
+
46
+
47
+ def _load_yaml(path: Path) -> Any:
48
+ with open(path, encoding="utf-8") as f:
49
+ return yaml.safe_load(f)
50
+
51
+
52
+ def _build_skill_map(skills: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
53
+ return {s["name"]: s for s in skills}
54
+
55
+
56
+ def _validate_deps(
57
+ skill_map: Dict[str, Dict[str, Any]],
58
+ ) -> Optional[List[str]]:
59
+ dangling: List[str] = []
60
+ for name, skill in skill_map.items():
61
+ for dep in skill.get("depends_on", []) or []:
62
+ if dep not in skill_map:
63
+ dangling.append(f"{name} depends on unknown skill '{dep}'")
64
+ return dangling if dangling else None
65
+
66
+
67
+ def compute(state_file: str) -> int:
68
+ path = Path(state_file)
69
+ if not path.is_file():
70
+ _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
71
+ return 2
72
+
73
+ try:
74
+ state = _load_yaml(path)
75
+ except Exception as exc:
76
+ _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
77
+ return 2
78
+
79
+ skills = state.get("skills", [])
80
+ if not isinstance(skills, list):
81
+ _emit_error("State file 'skills' is not an array", "INVALID_STATE")
82
+ return 2
83
+
84
+ skill_map = _build_skill_map(skills)
85
+
86
+ tier_counts = {"A": 0, "B": 0}
87
+ for skill in skill_map.values():
88
+ tier = skill.get("tier")
89
+ if tier in tier_counts:
90
+ tier_counts[tier] += 1
91
+
92
+ dangling = _validate_deps(skill_map)
93
+ if dangling:
94
+ _emit_error(
95
+ f"Dangling dependency references: {'; '.join(dangling)}",
96
+ "DANGLING_DEPENDENCY",
97
+ )
98
+ return 1
99
+
100
+ in_degree: Dict[str, int] = {name: 0 for name in skill_map}
101
+ adjacency: Dict[str, List[str]] = {name: [] for name in skill_map}
102
+
103
+ for name, skill in skill_map.items():
104
+ for dep in skill.get("depends_on", []) or []:
105
+ adjacency[dep].append(name)
106
+ in_degree[name] += 1
107
+
108
+ def _tier_key(n: str) -> tuple[int, str]:
109
+ return (0 if skill_map[n].get("tier") == "A" else 1, n)
110
+
111
+ heap: List[tuple[int, str]] = []
112
+ for n in skill_map:
113
+ if in_degree[n] == 0:
114
+ heapq.heappush(heap, _tier_key(n))
115
+
116
+ execution_order: List[str] = []
117
+
118
+ while heap:
119
+ _, current = heapq.heappop(heap)
120
+ execution_order.append(current)
121
+
122
+ for dependent in adjacency[current]:
123
+ in_degree[dependent] -= 1
124
+ if in_degree[dependent] == 0:
125
+ heapq.heappush(heap, _tier_key(dependent))
126
+
127
+ if len(execution_order) < len(skill_map):
128
+ cycle_participants = sorted(
129
+ n for n in skill_map if n not in set(execution_order)
130
+ )
131
+ output = {
132
+ "execution_order": execution_order,
133
+ "circular_deps_detected": True,
134
+ "cycle_participants": cycle_participants,
135
+ "tier_counts": tier_counts,
136
+ }
137
+ json.dump(output, sys.stdout, separators=(",", ":"))
138
+ sys.stdout.write("\n")
139
+ return 1
140
+
141
+ output = {
142
+ "execution_order": execution_order,
143
+ "circular_deps_detected": False,
144
+ "cycle_participants": None,
145
+ "tier_counts": tier_counts,
146
+ }
147
+ json.dump(output, sys.stdout, separators=(",", ":"))
148
+ sys.stdout.write("\n")
149
+ return 0
150
+
151
+
152
+ def check(state_file: str, skill_name: str, force: bool = False) -> int:
153
+ path = Path(state_file)
154
+ if not path.is_file():
155
+ _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
156
+ return 2
157
+
158
+ try:
159
+ state = _load_yaml(path)
160
+ except Exception as exc:
161
+ _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
162
+ return 2
163
+
164
+ skills = state.get("skills", [])
165
+ if not isinstance(skills, list):
166
+ _emit_error("State file 'skills' is not an array", "INVALID_STATE")
167
+ return 2
168
+
169
+ skill_map = _build_skill_map(skills)
170
+
171
+ if skill_name not in skill_map:
172
+ _emit_error(f"Skill '{skill_name}' not found in state", "SKILL_NOT_FOUND")
173
+ return 2
174
+
175
+ deps = skill_map[skill_name].get("depends_on", []) or []
176
+ unmet: List[str] = []
177
+ for dep in deps:
178
+ if dep not in skill_map:
179
+ _emit_error(
180
+ f"Skill '{skill_name}' depends on unknown skill '{dep}'",
181
+ "DANGLING_DEPENDENCY",
182
+ )
183
+ return 1
184
+ if skill_map[dep].get("status") != "completed":
185
+ unmet.append(dep)
186
+
187
+ ready = len(unmet) == 0
188
+ forced = force and not ready
189
+
190
+ if forced:
191
+ json.dump(
192
+ {"warning": f"Forcing past unmet dependencies for '{skill_name}'", "unmet": unmet},
193
+ sys.stderr,
194
+ )
195
+ sys.stderr.write("\n")
196
+
197
+ output = {
198
+ "skill": skill_name,
199
+ "ready": ready,
200
+ "unmet_deps": unmet,
201
+ "forced": forced,
202
+ }
203
+ json.dump(output, sys.stdout, separators=(",", ":"))
204
+ sys.stdout.write("\n")
205
+
206
+ if not ready and not force:
207
+ return 1
208
+ return 0
209
+
210
+
211
+ def main() -> int:
212
+ parser = argparse.ArgumentParser(
213
+ description="Campaign dependency computation and enforcement.",
214
+ )
215
+ mode = parser.add_mutually_exclusive_group(required=True)
216
+ mode.add_argument(
217
+ "--compute",
218
+ action="store_true",
219
+ help="Compute execution order via topological sort",
220
+ )
221
+ mode.add_argument(
222
+ "--check",
223
+ action="store_true",
224
+ help="Check if a skill's dependencies are satisfied",
225
+ )
226
+ parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
227
+ parser.add_argument("--skill", help="Skill name (required for --check)")
228
+ parser.add_argument(
229
+ "--force",
230
+ action="store_true",
231
+ help="Force past dependency check (--check only)",
232
+ )
233
+ args = parser.parse_args()
234
+
235
+ if args.check and not args.skill:
236
+ parser.error("--skill is required when using --check")
237
+
238
+ if args.compute:
239
+ return compute(args.state_file)
240
+ return check(args.state_file, args.skill, force=args.force)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """Campaign Parse Manifest — deterministic parse of a --manifest target list.
6
+
7
+ step-01 (Setup) seeds a headless campaign's targets from a plain-text manifest.
8
+ Parsing that format by hand risks silently dropping a malformed line — exactly
9
+ the kind of mechanical, all-or-nothing work that belongs in a script. This
10
+ parser is the single source of truth for the format documented in SKILL.md
11
+ "On Activation".
12
+
13
+ Format (one target per line):
14
+
15
+ name,repo_url,tier,pin[;dep1,dep2,...]
16
+
17
+ - `pin` may be empty (latest): `name,repo_url,tier,` or `name,repo_url,tier`
18
+ - a trailing `;`-segment lists depends_on names (comma-separated)
19
+ - blank lines and lines starting with `#` are skipped
20
+ - `tier` must be `A` or `B`
21
+
22
+ CLI:
23
+ uv run campaign-parse-manifest.py <path/to/manifest.txt>
24
+ cat manifest.txt | uv run campaign-parse-manifest.py -
25
+
26
+ Output (JSON on stdout):
27
+ {"targets": [{"name","repo_url","tier","pin","depends_on"}], "errors": [{"line","message"}]}
28
+
29
+ Exit codes:
30
+ 0 parsed cleanly (no errors)
31
+ 1 one or more malformed lines (errors[] populated; targets[] omits bad lines)
32
+ 2 file error (not found / unreadable)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ from pathlib import Path
41
+ from typing import Any, Dict, List
42
+
43
+
44
+ def parse_manifest_text(text: str) -> Dict[str, Any]:
45
+ targets: List[Dict[str, Any]] = []
46
+ errors: List[Dict[str, Any]] = []
47
+ seen: set = set()
48
+
49
+ for lineno, raw in enumerate(text.splitlines(), start=1):
50
+ line = raw.strip()
51
+ if not line or line.startswith("#"):
52
+ continue
53
+
54
+ body, sep, deps_part = line.partition(";")
55
+ fields = [f.strip() for f in body.split(",")]
56
+ if len(fields) < 3:
57
+ errors.append({"line": lineno, "message": f"expected `name,repo_url,tier[,pin]`, got {len(fields)} field(s)"})
58
+ continue
59
+
60
+ name, repo_url, tier = fields[0], fields[1], fields[2]
61
+ pin = fields[3] if len(fields) >= 4 and fields[3] != "" else None
62
+
63
+ if not name:
64
+ errors.append({"line": lineno, "message": "empty `name`"})
65
+ continue
66
+ if not repo_url:
67
+ errors.append({"line": lineno, "message": f"`{name}` has empty `repo_url`"})
68
+ continue
69
+ if tier not in ("A", "B"):
70
+ errors.append({"line": lineno, "message": f"`{name}` has invalid tier `{tier}` (must be A or B)"})
71
+ continue
72
+ if name in seen:
73
+ errors.append({"line": lineno, "message": f"duplicate target name `{name}`"})
74
+ continue
75
+ seen.add(name)
76
+
77
+ depends_on = [d.strip() for d in deps_part.split(",") if d.strip()] if sep else []
78
+
79
+ targets.append(
80
+ {"name": name, "repo_url": repo_url, "tier": tier, "pin": pin, "depends_on": depends_on}
81
+ )
82
+
83
+ return {"targets": targets, "errors": errors}
84
+
85
+
86
+ def run(path: str) -> int:
87
+ if path == "-":
88
+ text = sys.stdin.read()
89
+ else:
90
+ p = Path(path)
91
+ if not p.is_file():
92
+ json.dump({"error": f"Manifest not found: {path}", "code": "MANIFEST_NOT_FOUND"}, sys.stderr)
93
+ sys.stderr.write("\n")
94
+ return 2
95
+ try:
96
+ text = p.read_text(encoding="utf-8")
97
+ except OSError as exc:
98
+ json.dump({"error": f"Manifest unreadable: {exc}", "code": "MANIFEST_READ_ERROR"}, sys.stderr)
99
+ sys.stderr.write("\n")
100
+ return 2
101
+
102
+ result = parse_manifest_text(text)
103
+ json.dump(result, sys.stdout, separators=(",", ":"))
104
+ sys.stdout.write("\n")
105
+ return 1 if result["errors"] else 0
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ parser = argparse.ArgumentParser(
110
+ prog="campaign-parse-manifest",
111
+ description="Parse a --manifest target list into structured targets.",
112
+ )
113
+ parser.add_argument("path", help="path to manifest text file, or `-` for stdin")
114
+ args = parser.parse_args(argv)
115
+ return run(args.path)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ raise SystemExit(main())
@@ -0,0 +1,247 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """Campaign Provenance — verify repo access and record commit SHAs for all targets.
6
+
7
+ Replaces the step-04 prose that asked the LLM to string-munge each repo_url
8
+ ("handle trailing .git or slashes"), run `gh repo view` + `gh api commits/{ref}`
9
+ per target, and aggregate failures across 15+ targets in-context. All of that
10
+ is deterministic; doing it by hand is both token-expensive and a fragile-parse
11
+ risk. This script owns the parse, the gh calls, and the aggregation, and — when
12
+ every (or nearly every) target fails the same way — collapses the wall of
13
+ near-identical errors into a single actionable root-cause hint instead of N
14
+ independent failures.
15
+
16
+ For each skill it resolves `{owner}/{repo}` from the brief's repo_url, picks the
17
+ ref (the skill's pin, or the repo default branch), verifies access, and records
18
+ the commit SHA.
19
+
20
+ CLI:
21
+ uv run campaign-provenance.py --state-file <path> --brief-file <path>
22
+
23
+ Output (JSON on stdout):
24
+ {
25
+ "results": [
26
+ {"name": "...", "repo_url": "...", "owner": "...", "repo": "...",
27
+ "ref": "...", "commit_sha": "..." | null,
28
+ "status": "accessible" | "inaccessible", "error": "..." | null}
29
+ ],
30
+ "all_accessible": bool,
31
+ "inaccessible_count": N,
32
+ "systemic_hint": "..." | null
33
+ }
34
+
35
+ Exit codes:
36
+ 0 all targets accessible
37
+ 1 one or more targets inaccessible
38
+ 2 error (missing files, bad YAML, gh not installed)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import re
46
+ import shutil
47
+ import subprocess # noqa: S404 — invoking the user's authenticated `gh` CLI is the point
48
+ import sys
49
+ from pathlib import Path
50
+ from typing import Any, Callable, Dict, List, Optional, Tuple
51
+
52
+ import yaml
53
+
54
+ # A command runner returns (returncode, stdout, stderr). Injectable for tests.
55
+ Runner = Callable[[List[str]], Tuple[int, str, str]]
56
+
57
+
58
+ def _emit_error(message: str, code: str) -> None:
59
+ json.dump({"error": message, "code": code}, sys.stderr)
60
+ sys.stderr.write("\n")
61
+
62
+
63
+ def _load_yaml(path: Path) -> Any:
64
+ with open(path, encoding="utf-8") as f:
65
+ return yaml.safe_load(f)
66
+
67
+
68
+ def parse_owner_repo(repo_url: str) -> Optional[Tuple[str, str]]:
69
+ """Extract (owner, repo) from a GitHub URL or `owner/repo` shorthand.
70
+
71
+ Tolerates trailing `.git`, trailing slashes, `git@` SSH form, and a bare
72
+ `owner/repo`. Returns None when no owner/repo pair can be recovered.
73
+ """
74
+ if not repo_url or not isinstance(repo_url, str):
75
+ return None
76
+ url = repo_url.strip().rstrip("/")
77
+ if url.endswith(".git"):
78
+ url = url[:-4]
79
+ # git@github.com:owner/repo
80
+ ssh = re.match(r"^git@[^:]+:(?P<owner>[^/]+)/(?P<repo>[^/]+)$", url)
81
+ if ssh:
82
+ return ssh.group("owner"), ssh.group("repo")
83
+ # https://host/owner/repo (take the last two path segments)
84
+ https = re.match(r"^[a-zA-Z]+://[^/]+/(?P<rest>.+)$", url)
85
+ rest = https.group("rest") if https else url
86
+ parts = [p for p in rest.split("/") if p]
87
+ if len(parts) >= 2:
88
+ return parts[-2], parts[-1]
89
+ return None
90
+
91
+
92
+ def _default_runner(args: List[str]) -> Tuple[int, str, str]:
93
+ proc = subprocess.run(args, capture_output=True, text=True) # noqa: S603
94
+ return proc.returncode, proc.stdout, proc.stderr
95
+
96
+
97
+ def _classify_error(stderr: str) -> str:
98
+ """Bucket a gh failure so systemic root causes can be detected."""
99
+ low = stderr.lower()
100
+ if "authentication" in low or "gh auth" in low or "not logged" in low or "401" in low:
101
+ return "auth"
102
+ if "could not resolve" in low or "network" in low or "timeout" in low or "dial tcp" in low:
103
+ return "network"
104
+ if "rate limit" in low or "403" in low:
105
+ return "rate-limit"
106
+ if "not found" in low or "404" in low:
107
+ return "not-found"
108
+ return "other"
109
+
110
+
111
+ _SYSTEMIC_HINTS = {
112
+ "auth": "All targets failed authentication — run `gh auth status` / `gh auth login`, then `campaign resume`.",
113
+ "network": "All targets failed with network errors — check connectivity, then `campaign resume`.",
114
+ "rate-limit": "All targets hit GitHub rate limiting — wait for the limit to reset, then `campaign resume`.",
115
+ }
116
+
117
+
118
+ def run(state_file: str, brief_file: str, runner: Runner = _default_runner) -> int:
119
+ state_path = Path(state_file)
120
+ brief_path = Path(brief_file)
121
+
122
+ if not state_path.is_file():
123
+ _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
124
+ return 2
125
+ if not brief_path.is_file():
126
+ _emit_error(f"Brief file not found: {brief_file}", "BRIEF_NOT_FOUND")
127
+ return 2
128
+ if shutil.which("gh") is None and runner is _default_runner:
129
+ _emit_error("GitHub CLI `gh` not found on PATH", "GH_NOT_FOUND")
130
+ return 2
131
+
132
+ try:
133
+ state = _load_yaml(state_path)
134
+ except Exception as exc: # noqa: BLE001
135
+ _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
136
+ return 2
137
+ try:
138
+ brief = _load_yaml(brief_path)
139
+ except Exception as exc: # noqa: BLE001
140
+ _emit_error(f"Failed to parse brief file: {exc}", "BRIEF_PARSE_ERROR")
141
+ return 2
142
+
143
+ skills = state.get("skills", [])
144
+ if not isinstance(skills, list):
145
+ _emit_error("State file 'skills' is not an array", "INVALID_STATE")
146
+ return 2
147
+ targets = brief.get("targets", [])
148
+ if not isinstance(targets, list):
149
+ _emit_error("Brief file 'targets' is not an array", "INVALID_BRIEF")
150
+ return 2
151
+
152
+ name_to_repo: Dict[str, str] = {t["name"]: t["repo_url"] for t in targets}
153
+
154
+ results: List[Dict[str, Any]] = []
155
+ error_classes: List[str] = []
156
+
157
+ for skill in skills:
158
+ name = skill["name"]
159
+ repo_url = name_to_repo.get(name)
160
+ record: Dict[str, Any] = {
161
+ "name": name,
162
+ "repo_url": repo_url,
163
+ "owner": None,
164
+ "repo": None,
165
+ "ref": None,
166
+ "commit_sha": None,
167
+ "status": "inaccessible",
168
+ "error": None,
169
+ }
170
+ if repo_url is None:
171
+ record["error"] = f"Skill '{name}' has no repo_url in brief targets"
172
+ error_classes.append("other")
173
+ results.append(record)
174
+ continue
175
+
176
+ parsed = parse_owner_repo(repo_url)
177
+ if parsed is None:
178
+ record["error"] = f"Could not parse owner/repo from '{repo_url}'"
179
+ error_classes.append("other")
180
+ results.append(record)
181
+ continue
182
+ owner, repo = parsed
183
+ record["owner"], record["repo"] = owner, repo
184
+
185
+ rc, _out, err = runner(["gh", "repo", "view", f"{owner}/{repo}", "--json", "name"])
186
+ if rc != 0:
187
+ record["error"] = err.strip() or "gh repo view failed"
188
+ error_classes.append(_classify_error(err))
189
+ results.append(record)
190
+ continue
191
+
192
+ ref = skill.get("pin")
193
+ if not ref:
194
+ rc, out, err = runner(
195
+ ["gh", "repo", "view", f"{owner}/{repo}", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]
196
+ )
197
+ if rc != 0:
198
+ record["error"] = err.strip() or "could not resolve default branch"
199
+ error_classes.append(_classify_error(err))
200
+ results.append(record)
201
+ continue
202
+ ref = out.strip()
203
+ record["ref"] = ref
204
+
205
+ rc, out, err = runner(["gh", "api", f"repos/{owner}/{repo}/commits/{ref}", "--jq", ".sha"])
206
+ if rc != 0:
207
+ record["error"] = err.strip() or f"could not resolve commit for ref '{ref}'"
208
+ error_classes.append(_classify_error(err))
209
+ results.append(record)
210
+ continue
211
+
212
+ record["commit_sha"] = out.strip()
213
+ record["status"] = "accessible"
214
+ results.append(record)
215
+
216
+ inaccessible = [r for r in results if r["status"] != "accessible"]
217
+ systemic_hint: Optional[str] = None
218
+ if inaccessible and len(inaccessible) == len(results):
219
+ # Every target failed — if they share a class, surface one root cause.
220
+ distinct = set(error_classes)
221
+ if len(distinct) == 1:
222
+ systemic_hint = _SYSTEMIC_HINTS.get(next(iter(distinct)))
223
+
224
+ output = {
225
+ "results": results,
226
+ "all_accessible": not inaccessible,
227
+ "inaccessible_count": len(inaccessible),
228
+ "systemic_hint": systemic_hint,
229
+ }
230
+ json.dump(output, sys.stdout, separators=(",", ":"))
231
+ sys.stdout.write("\n")
232
+ return 0 if not inaccessible else 1
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ parser = argparse.ArgumentParser(
237
+ prog="campaign-provenance",
238
+ description="Verify repo access and record commit SHAs for all campaign targets.",
239
+ )
240
+ parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
241
+ parser.add_argument("--brief-file", required=True, help="Path to campaign-brief.yaml")
242
+ args = parser.parse_args(argv)
243
+ return run(args.state_file, args.brief_file)
244
+
245
+
246
+ if __name__ == "__main__":
247
+ raise SystemExit(main())