@ikon85/agent-workflow-kit 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +139 -0
  2. package/.agents/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
  3. package/.agents/skills/audit-skills/SKILL.md +145 -0
  4. package/.agents/skills/board-to-waves/SKILL.md +135 -0
  5. package/.agents/skills/code-review/SKILL.md +64 -0
  6. package/.agents/skills/codebase-design/DEEPENING.md +37 -0
  7. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  8. package/.agents/skills/codebase-design/SKILL.md +114 -0
  9. package/.agents/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
  10. package/.agents/skills/codex-adapter-sync/SKILL.md +151 -0
  11. package/.agents/skills/decision-gate/SKILL.md +67 -0
  12. package/.agents/skills/diagnose/SKILL.md +136 -0
  13. package/.agents/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
  14. package/.agents/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  15. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  16. package/.agents/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  17. package/.agents/skills/domain-modeling/SKILL.md +76 -0
  18. package/.agents/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
  19. package/.agents/skills/git-worktree-recover/SKILL.md +176 -0
  20. package/.agents/skills/grill-me/SKILL.md +54 -0
  21. package/.agents/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
  22. package/.agents/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  23. package/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  24. package/.agents/skills/grill-with-docs/SKILL.md +137 -0
  25. package/.agents/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
  26. package/.agents/skills/implement/SKILL.md +15 -0
  27. package/.agents/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
  28. package/.agents/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
  29. package/.agents/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
  30. package/.agents/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  31. package/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  32. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
  33. package/.agents/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  34. package/.agents/skills/improve-codebase-architecture/SKILL.md +88 -0
  35. package/.agents/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
  36. package/.agents/skills/kit-release/SKILL.md +56 -0
  37. package/.agents/skills/local-ci/SKILL.md +94 -0
  38. package/.agents/skills/orchestrate-wave/SKILL.md +261 -0
  39. package/.agents/skills/orchestrate-wave/references/builder-contract.md +67 -0
  40. package/.agents/skills/prototype/LOGIC.md +79 -0
  41. package/.agents/skills/prototype/SKILL.md +30 -0
  42. package/.agents/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
  43. package/.agents/skills/prototype/UI.md +112 -0
  44. package/.agents/skills/research/SKILL.md +12 -0
  45. package/.agents/skills/research/THIRD-PARTY-NOTICES.md +29 -0
  46. package/.agents/skills/resolving-merge-conflicts/SKILL.md +14 -0
  47. package/.agents/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
  48. package/.agents/skills/retro/SKILL.md +209 -0
  49. package/.agents/skills/scale-check/SKILL.md +141 -0
  50. package/.agents/skills/security-audit/SKILL.md +67 -0
  51. package/.agents/skills/setup-workflow/SKILL.md +187 -0
  52. package/.agents/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
  53. package/.agents/skills/setup-workflow/board-sync.md +111 -0
  54. package/.agents/skills/setup-workflow/code-review.md +26 -0
  55. package/.agents/skills/setup-workflow/domain.md +51 -0
  56. package/.agents/skills/setup-workflow/issue-tracker-github.md +35 -0
  57. package/.agents/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
  58. package/.agents/skills/setup-workflow/issue-tracker-local.md +30 -0
  59. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
  60. package/.agents/skills/setup-workflow/spec-completeness-seed.md +44 -0
  61. package/.agents/skills/setup-workflow/spec-self-critique-seed.md +43 -0
  62. package/.agents/skills/setup-workflow/triage-labels.md +18 -0
  63. package/.agents/skills/setup-workflow/workflow-overview.md +33 -0
  64. package/.agents/skills/spec-self-critique/SKILL.md +152 -0
  65. package/.agents/skills/spec-self-critique/scenarios.md +82 -0
  66. package/.agents/skills/tdd/SKILL.md +120 -0
  67. package/.agents/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
  68. package/.agents/skills/tdd/deep-modules.md +33 -0
  69. package/.agents/skills/tdd/interface-design.md +31 -0
  70. package/.agents/skills/tdd/mocking.md +59 -0
  71. package/.agents/skills/tdd/refactoring.md +10 -0
  72. package/.agents/skills/tdd/tests.md +61 -0
  73. package/.agents/skills/to-issues/SKILL.md +325 -0
  74. package/.agents/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
  75. package/.agents/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
  76. package/.agents/skills/to-prd/SKILL.md +180 -0
  77. package/.agents/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
  78. package/.agents/skills/to-waves/SKILL.md +260 -0
  79. package/.agents/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
  80. package/.agents/skills/triage/AGENT-BRIEF.md +168 -0
  81. package/.agents/skills/triage/OUT-OF-SCOPE.md +101 -0
  82. package/.agents/skills/triage/SKILL.md +122 -0
  83. package/.agents/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
  84. package/.agents/skills/verify-spike/SKILL.md +39 -0
  85. package/.agents/skills/wayfinder/SKILL.md +127 -0
  86. package/.agents/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
  87. package/.agents/skills/wrapup/SKILL.md +71 -0
  88. package/.agents/skills/wrapup/SOURCES.txt +6 -0
  89. package/.agents/skills/zoom-out/SKILL.md +7 -0
  90. package/.agents/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
  91. package/.claude/hooks/_hook_utils.py +184 -0
  92. package/.claude/hooks/drift-guard.py +176 -0
  93. package/.claude/hooks/skill-drift-hint.py +122 -0
  94. package/.claude/hooks/sync-board-status.py +191 -0
  95. package/.claude/skills/ask-matt/SKILL.md +139 -0
  96. package/.claude/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
  97. package/.claude/skills/audit-skills/SKILL.md +145 -0
  98. package/.claude/skills/board-to-waves/SKILL.md +148 -0
  99. package/.claude/skills/code-review/SKILL.md +64 -0
  100. package/.claude/skills/codebase-design/DEEPENING.md +37 -0
  101. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  102. package/.claude/skills/codebase-design/SKILL.md +114 -0
  103. package/.claude/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
  104. package/.claude/skills/codex-build/SKILL.md +126 -0
  105. package/.claude/skills/codex-build/THIRD-PARTY-NOTICES.md +35 -0
  106. package/.claude/skills/codex-review/SKILL.md +145 -0
  107. package/.claude/skills/codex-review/THIRD-PARTY-NOTICES.md +31 -0
  108. package/.claude/skills/decision-gate/SKILL.md +67 -0
  109. package/.claude/skills/diagnose/SKILL.md +136 -0
  110. package/.claude/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
  111. package/.claude/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  112. package/.claude/skills/domain-modeling/ADR-FORMAT.md +47 -0
  113. package/.claude/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  114. package/.claude/skills/domain-modeling/SKILL.md +76 -0
  115. package/.claude/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
  116. package/.claude/skills/git-guardrails-claude-code/SKILL.md +96 -0
  117. package/.claude/skills/git-guardrails-claude-code/THIRD-PARTY-NOTICES.md +29 -0
  118. package/.claude/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +28 -0
  119. package/.claude/skills/git-worktree-recover/SKILL.md +182 -0
  120. package/.claude/skills/grill-me/SKILL.md +54 -0
  121. package/.claude/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
  122. package/.claude/skills/grill-me-codex/SKILL.md +158 -0
  123. package/.claude/skills/grill-me-codex/THIRD-PARTY-NOTICES.md +32 -0
  124. package/.claude/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  125. package/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  126. package/.claude/skills/grill-with-docs/SKILL.md +137 -0
  127. package/.claude/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
  128. package/.claude/skills/grill-with-docs-codex/ADR-FORMAT.md +47 -0
  129. package/.claude/skills/grill-with-docs-codex/CONTEXT-FORMAT.md +60 -0
  130. package/.claude/skills/grill-with-docs-codex/SKILL.md +233 -0
  131. package/.claude/skills/grill-with-docs-codex/THIRD-PARTY-NOTICES.md +33 -0
  132. package/.claude/skills/implement/SKILL.md +15 -0
  133. package/.claude/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
  134. package/.claude/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
  135. package/.claude/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
  136. package/.claude/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  137. package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  138. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
  139. package/.claude/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  140. package/.claude/skills/improve-codebase-architecture/SKILL.md +88 -0
  141. package/.claude/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
  142. package/.claude/skills/kit-release/SKILL.md +56 -0
  143. package/.claude/skills/local-ci/SKILL.md +94 -0
  144. package/.claude/skills/orchestrate-wave/SKILL.md +269 -0
  145. package/.claude/skills/orchestrate-wave/references/builder-contract.md +67 -0
  146. package/.claude/skills/prototype/LOGIC.md +79 -0
  147. package/.claude/skills/prototype/SKILL.md +30 -0
  148. package/.claude/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
  149. package/.claude/skills/prototype/UI.md +112 -0
  150. package/.claude/skills/research/SKILL.md +12 -0
  151. package/.claude/skills/research/THIRD-PARTY-NOTICES.md +29 -0
  152. package/.claude/skills/resolving-merge-conflicts/SKILL.md +14 -0
  153. package/.claude/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
  154. package/.claude/skills/retro/SKILL.md +209 -0
  155. package/.claude/skills/scale-check/SKILL.md +141 -0
  156. package/.claude/skills/security-audit/SKILL.md +67 -0
  157. package/.claude/skills/setup-pre-commit/SKILL.md +104 -0
  158. package/.claude/skills/setup-pre-commit/scripts/pre-commit.template.sh +35 -0
  159. package/.claude/skills/setup-workflow/SKILL.md +187 -0
  160. package/.claude/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
  161. package/.claude/skills/setup-workflow/board-sync.md +111 -0
  162. package/.claude/skills/setup-workflow/code-review.md +26 -0
  163. package/.claude/skills/setup-workflow/domain.md +51 -0
  164. package/.claude/skills/setup-workflow/issue-tracker-github.md +35 -0
  165. package/.claude/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
  166. package/.claude/skills/setup-workflow/issue-tracker-local.md +30 -0
  167. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
  168. package/.claude/skills/setup-workflow/spec-completeness-seed.md +44 -0
  169. package/.claude/skills/setup-workflow/spec-self-critique-seed.md +43 -0
  170. package/.claude/skills/setup-workflow/triage-labels.md +18 -0
  171. package/.claude/skills/setup-workflow/workflow-overview.md +33 -0
  172. package/.claude/skills/skill-manifest.json +389 -0
  173. package/.claude/skills/spec-self-critique/SKILL.md +152 -0
  174. package/.claude/skills/spec-self-critique/scenarios.md +82 -0
  175. package/.claude/skills/tdd/SKILL.md +120 -0
  176. package/.claude/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
  177. package/.claude/skills/tdd/deep-modules.md +33 -0
  178. package/.claude/skills/tdd/interface-design.md +31 -0
  179. package/.claude/skills/tdd/mocking.md +59 -0
  180. package/.claude/skills/tdd/refactoring.md +10 -0
  181. package/.claude/skills/tdd/tests.md +61 -0
  182. package/.claude/skills/to-issues/SKILL.md +325 -0
  183. package/.claude/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
  184. package/.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
  185. package/.claude/skills/to-prd/SKILL.md +180 -0
  186. package/.claude/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
  187. package/.claude/skills/to-waves/SKILL.md +260 -0
  188. package/.claude/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
  189. package/.claude/skills/triage/AGENT-BRIEF.md +168 -0
  190. package/.claude/skills/triage/OUT-OF-SCOPE.md +101 -0
  191. package/.claude/skills/triage/SKILL.md +122 -0
  192. package/.claude/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
  193. package/.claude/skills/verify-spike/SKILL.md +39 -0
  194. package/.claude/skills/wayfinder/SKILL.md +127 -0
  195. package/.claude/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
  196. package/.claude/skills/wrapup/SKILL.md +81 -0
  197. package/.claude/skills/wrapup/SOURCES.txt +6 -0
  198. package/.claude/skills/write-a-skill/GLOSSARY.md +201 -0
  199. package/.claude/skills/write-a-skill/SKILL.md +88 -0
  200. package/.claude/skills/write-a-skill/THIRD-PARTY-NOTICES.md +29 -0
  201. package/.claude/skills/zoom-out/SKILL.md +7 -0
  202. package/.claude/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
  203. package/LICENSE +28 -0
  204. package/PROVENANCE.md +39 -0
  205. package/README.md +619 -0
  206. package/agent-workflow-kit.package.json +1939 -0
  207. package/docs/agents/board-sync.md +71 -0
  208. package/docs/agents/code-review.md +14 -0
  209. package/docs/agents/domain.md +7 -0
  210. package/docs/agents/issue-tracker.md +12 -0
  211. package/docs/agents/security-audit-runbook-template.md +205 -0
  212. package/docs/agents/skills/orchestrate-wave.md +13 -0
  213. package/docs/agents/skills/spec-self-critique.md +18 -0
  214. package/docs/agents/triage-labels.md +10 -0
  215. package/docs/agents/wave-anchor-template.md +88 -0
  216. package/docs/conventions/spec-completeness.md +23 -0
  217. package/docs/index.html +56 -0
  218. package/docs/methodology.html +488 -0
  219. package/docs/methodology.svg +93 -0
  220. package/docs/workflow.png +0 -0
  221. package/package.json +40 -0
  222. package/scripts/anchor_table.py +343 -0
  223. package/scripts/board-sync.py +1232 -0
  224. package/scripts/board_config.py +147 -0
  225. package/scripts/board_fields.py +192 -0
  226. package/scripts/build-kit.mjs +116 -0
  227. package/scripts/build-kit.test.mjs +59 -0
  228. package/scripts/check-kit-staleness.mjs +45 -0
  229. package/scripts/check-kit-staleness.test.mjs +19 -0
  230. package/scripts/execute-ready-check.py +516 -0
  231. package/scripts/issue_deps.py +79 -0
  232. package/scripts/kit-release.mjs +100 -0
  233. package/scripts/kit-release.test.mjs +67 -0
  234. package/scripts/lib/audit-refs.mjs +146 -0
  235. package/scripts/lib/scrub.mjs +113 -0
  236. package/scripts/lib/scrub.test.mjs +187 -0
  237. package/scripts/loc_offender_core.py +196 -0
  238. package/scripts/loc_offender_gate.py +182 -0
  239. package/scripts/node_kind.py +68 -0
  240. package/scripts/portability_profile_scan.py +53 -0
  241. package/scripts/pr-body-check.py +263 -0
  242. package/scripts/program_graph.py +125 -0
  243. package/scripts/program_graph_parse.py +275 -0
  244. package/scripts/program_graph_validate.py +256 -0
  245. package/scripts/program_sync.py +160 -0
  246. package/scripts/release-delta-guard.mjs +96 -0
  247. package/scripts/release-delta-guard.test.mjs +53 -0
  248. package/scripts/test_board_sync_create_idempotency.py +104 -0
  249. package/scripts/test_dist_kit_smoke.py +199 -0
  250. package/scripts/test_kit_docs_language_census.py +178 -0
  251. package/scripts/test_skill_code_review_seed.py +89 -0
  252. package/scripts/test_skill_frontmatter_lint.py +226 -0
  253. package/scripts/test_skill_gh_lint.py +124 -0
  254. package/scripts/test_skill_language_census.py +254 -0
  255. package/scripts/test_skill_portability_lint.py +773 -0
  256. package/scripts/test_skill_publish_audit.py +300 -0
  257. package/scripts/test_skill_selfcontainment_lint.py +255 -0
  258. package/scripts/test_skill_setup_workflow_seeds.py +200 -0
  259. package/scripts/test_skill_stale_name_lint.py +126 -0
  260. package/scripts/test_skill_surface_refs.py +300 -0
  261. package/scripts/test_skill_trailing_artifact_lint.py +71 -0
  262. package/scripts/wrapup-land.py +573 -0
  263. package/src/cli.mjs +70 -0
  264. package/src/commands/diff.mjs +6 -0
  265. package/src/commands/init.mjs +59 -0
  266. package/src/commands/uninstall.mjs +43 -0
  267. package/src/commands/update.mjs +95 -0
  268. package/src/lib/atomicWrite.mjs +49 -0
  269. package/src/lib/bundle.mjs +133 -0
  270. package/src/lib/hash.mjs +13 -0
  271. package/src/lib/manifest.mjs +58 -0
  272. package/src/lib/sentinel.mjs +20 -0
  273. package/src/lib/settings.mjs +23 -0
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pr-body-check.py — mechanical guard for the PR-body conventions that were until
4
+ now prose-only.
5
+
6
+ Called by `wrapup` Step 0c AFTER the PR has been created/reused, BEFORE the
7
+ merge gate. Turns three instruction-only rules into a check that actually fires:
8
+
9
+ 1. Anker-Slice (Issue HAS a parent) → body MUST contain `Part of #<parent>`
10
+ and MUST NOT contain a close-keyword on the **parent anchor** number
11
+ (close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved, case-insens.)
12
+ — the incident: `closes #<anker>` on a slice-PR closed the wave anchor.
13
+ A `closes #<foreign-leaf>` (any number ≠ parent) stays ALLOWED.
14
+ 2. Atomar Leaf (Issue has NO parent / FREI) → body MUST contain an ACTIVE
15
+ `closes #<issue>` — not inside a code span / backticks (else GitHub's
16
+ auto-close never fires, wrapup Step 5b lesson).
17
+ 3. `**Retro:**`-Pflichtzeile present in one of the Slice-7 forms
18
+ (`gefahren …` | `übersprungen …`), with a space after the marker.
19
+
20
+ Scope: this script checks ONLY the closes/Part-of anchor rule and the
21
+ `**Retro:**` line. It does NOT parse or validate `annahme-drift` markers
22
+ (those are prose-/judgment-driven in wrapup Step 0c + 5e, deliberately not
23
+ mechanised — R2-F6). The annahme-drift block therefore runs BEFORE this
24
+ check in wrapup Step 0c so the body the script sees is final.
25
+
26
+ Exit codes:
27
+ 0 — green (no violations)
28
+ 1 — violation(s) found → wrapup STOPs, fix body via `gh pr edit --body-file`
29
+ 2 — not checkable (no issue-number derivable from the branch, or PR body
30
+ unavailable) → warn, do NOT block (fail-open, like drift-guard).
31
+
32
+ The pure functions below carry the logic + the unit tests; gh/git access is a
33
+ thin shell. NOT a hook — `wrapup` invokes it (Design).
34
+
35
+ Usage:
36
+ pr-body-check.py [--branch <name>] [--issue <n>] [--parent <n>|FREI]
37
+ [--body-file <path>]
38
+ All flags optional; unset → derived from the current branch + live PR via gh.
39
+
40
+ Audit log: .claude/logs/pr-body-check.log
41
+ """
42
+ import argparse
43
+ import re
44
+ import subprocess
45
+ import sys
46
+ from pathlib import Path
47
+
48
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
49
+ from board_config import ConfigError, load_board_config # noqa: E402
50
+
51
+ try:
52
+ _CFG = load_board_config()
53
+ except ConfigError as exc:
54
+ print(f"[FAIL] pr-body-check: Board-Profil nicht verfügbar — {exc}", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+
58
+ def _spaced(marker: str) -> str:
59
+ """Escape a marker, letting any run of whitespace inside it match `\\s+`."""
60
+ return r"\s+".join(re.escape(tok) for tok in marker.split())
61
+
62
+
63
+ LOG_DIR = Path(".claude/logs")
64
+ LOG_NAME = "pr-body-check"
65
+
66
+ # Branch prefixes + the `Part of` / `**Retro:**` markers are PROJECT conventions
67
+ # → read from the board profile. GitHub's auto-close keywords are a PLATFORM
68
+ # constant (GitHub only auto-closes on exactly this set), so they stay hardcoded
69
+ # here — configuring them would be a footgun, not a portability win.
70
+ BRANCH_ISSUE_RE = re.compile(
71
+ r"^(?:" + "|".join(re.escape(p) for p in _CFG["branchPrefixes"]) + r")/(\d+)-")
72
+ CLOSE_KEYWORDS = r"close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved"
73
+ RETRO_RE = re.compile(
74
+ re.escape(_CFG["prMarkers"]["retroMarker"]) + r"\s+("
75
+ + "|".join(re.escape(v) for v in _CFG["prMarkers"]["retroValues"]) + r")\b",
76
+ re.IGNORECASE)
77
+ PART_OF_RE_SRC = _spaced(_CFG["prMarkers"]["partOf"])
78
+ INLINE_CODE_RE = re.compile(r"`[^`]*`")
79
+ FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
80
+
81
+
82
+ def log(msg: str) -> None:
83
+ try:
84
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
85
+ from datetime import datetime
86
+ with (LOG_DIR / f"{LOG_NAME}.log").open("a", encoding="utf-8") as f:
87
+ f.write(f"{datetime.now().isoformat(timespec='seconds')} {msg}\n")
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ # --- pure parsers / checks --------------------------------------------------
93
+ def parse_issue_from_branch(branch: str):
94
+ """Issue number from `feat/<n>-slug` (also fix/chore/docs), or None."""
95
+ m = BRANCH_ISSUE_RE.match(branch or "")
96
+ return int(m.group(1)) if m else None
97
+
98
+
99
+ def strip_code(text: str) -> str:
100
+ """Remove fenced + inline code spans so a keyword that only lives inside
101
+ backticks does not count as active (GitHub ignores it for auto-close)."""
102
+ return INLINE_CODE_RE.sub(" ", FENCED_CODE_RE.sub(" ", text or ""))
103
+
104
+
105
+ def _close_on(body: str, n: int) -> bool:
106
+ """True if an ACTIVE close-keyword targets #n (outside code spans).
107
+
108
+ Matches `closes #n`, the colon form `closes: #n` (GitHub auto-closes on both
109
+ — the colon form was the R2-F3 bypass on the anchor guard), AND the
110
+ full-URL form `closes https://github.com/<owner>/<repo>/issues/<n>` (GitHub
111
+ auto-closes on this too — exactly the class bypass this script exists
112
+ to catch).
113
+ """
114
+ target = rf"(?:#0*{n}(?!\d)|https?://\S+/issues/0*{n}(?!\d))"
115
+ pat = re.compile(
116
+ rf"\b(?:{CLOSE_KEYWORDS})(?::\s*|\s+){target}", re.IGNORECASE)
117
+ return bool(pat.search(strip_code(body)))
118
+
119
+
120
+ def _part_of(body: str, n: int) -> bool:
121
+ return bool(re.search(
122
+ rf"\b{PART_OF_RE_SRC}(?::\s*|\s+)#0*{n}(?!\d)", body or "", re.IGNORECASE))
123
+
124
+
125
+ def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False):
126
+ """Return a list of violation strings ([] = green).
127
+
128
+ parent is the anchor issue number, or None for an atomar Leaf.
129
+ is_anchor: the branch issue IS itself a Wellen-Anker (type:cluster) —
130
+ Wave-PR-Fall: ein PR auf `feat/<anker#>-…` closed die Leaf-Issues,
131
+ nie den Anker selbst.
132
+ """
133
+ violations = []
134
+ body = body or ""
135
+
136
+ if parent is not None:
137
+ # Anker-Slice
138
+ if not _part_of(body, parent):
139
+ violations.append(
140
+ f"Anker-Slice: Body braucht `Part of #{parent}` (fehlt).")
141
+ if _close_on(body, parent):
142
+ violations.append(
143
+ f"Anker-Slice: Body enthält ein close-Keyword auf den Anker "
144
+ f"#{parent} — das schließt den Wellen-Anker beim Merge verfrüht "
145
+ f". `Part of #{parent}` nutzen, kein closes.")
146
+ elif is_anchor:
147
+ # Wave-PR auf dem Anker-Branch selbst (Branch trägt die Anker-Nummer)
148
+ if not _part_of(body, issue):
149
+ violations.append(
150
+ f"Wave-PR auf Anker-Branch: Body braucht `Part of #{issue}` (fehlt).")
151
+ if _close_on(body, issue):
152
+ violations.append(
153
+ f"Wave-PR: Body enthält ein close-Keyword auf den Anker #{issue} "
154
+ f"— das schließt den Anker beim Merge. Entfernen; die "
155
+ f"Leaf-Issues per `closes #<leaf>` schließen.")
156
+ else:
157
+ # Atomar Leaf
158
+ if not _close_on(body, issue):
159
+ violations.append(
160
+ f"Leaf-PR: aktives `closes #{issue}` fehlt (oder steht in "
161
+ f"Backticks → GitHub-Auto-Close greift dann nicht).")
162
+
163
+ if not RETRO_RE.search(body):
164
+ violations.append(
165
+ "Pflichtzeile `**Retro:** gefahren — …` oder "
166
+ "`**Retro:** übersprungen — <Grund>` fehlt.")
167
+
168
+ return violations
169
+
170
+
171
+ # --- thin git/gh shell ------------------------------------------------------
172
+ def _run(cmd, timeout=15):
173
+ try:
174
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
175
+ return r.returncode, r.stdout.strip()
176
+ except Exception:
177
+ return -1, ""
178
+
179
+
180
+ def current_branch():
181
+ rc, out = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
182
+ return out if rc == 0 and out else None
183
+
184
+
185
+ def fetch_parent(number: int):
186
+ """Parent issue number via board-sync.py, or None (FREI = atomar)."""
187
+ rc, out = _run(["python3", "scripts/board-sync.py", "parent-of", str(number)])
188
+ if rc != 0 or not out or out.strip() == "FREI":
189
+ return None
190
+ try:
191
+ return int(out.strip())
192
+ except ValueError:
193
+ return None
194
+
195
+
196
+ def fetch_pr_body(branch: str):
197
+ rc, out = _run(["gh", "pr", "view", branch, "--json", "body", "-q", ".body"])
198
+ return out if rc == 0 else None
199
+
200
+
201
+ def fetch_is_anchor(number: int) -> bool:
202
+ """True wenn das Issue selbst ein Wellen-Anker ist (Label type:cluster)."""
203
+ rc, out = _run(["gh", "issue", "view", str(number),
204
+ "--json", "labels", "-q", ".labels[].name"])
205
+ if rc != 0:
206
+ return False # fail-open in den Leaf-Pfad (bisheriges Verhalten)
207
+ return "type:cluster" in out.splitlines()
208
+
209
+
210
+ def main() -> int:
211
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
212
+ ap.add_argument("--branch", help="default: current git branch")
213
+ ap.add_argument("--issue", type=int, help="override branch-derived issue #")
214
+ ap.add_argument("--parent", help="override parent (number or FREI)")
215
+ ap.add_argument("--body-file", help="read PR body from file instead of gh")
216
+ args = ap.parse_args()
217
+
218
+ branch = args.branch or current_branch()
219
+ issue = args.issue if args.issue is not None else (
220
+ parse_issue_from_branch(branch) if branch else None)
221
+ if issue is None:
222
+ print(f"[WARN] pr-body-check: keine Issue-Nummer aus Branch '{branch}' "
223
+ f"ableitbar — übersprungen (kein Block).")
224
+ log(f"branch={branch} no-issue → exit 2")
225
+ return 2
226
+
227
+ if args.body_file:
228
+ try:
229
+ body = Path(args.body_file).read_text(encoding="utf-8")
230
+ except Exception as e:
231
+ print(f"[WARN] pr-body-check: --body-file unlesbar ({e}) — übersprungen.")
232
+ return 2
233
+ else:
234
+ body = fetch_pr_body(branch)
235
+ if body is None:
236
+ print(f"[WARN] pr-body-check: PR-Body für '{branch}' nicht abrufbar "
237
+ f"(kein PR? gh/Netz?) — übersprungen (kein Block).")
238
+ log(f"branch={branch} issue={issue} no-pr-body → exit 2")
239
+ return 2
240
+
241
+ if args.parent is not None:
242
+ parent = None if args.parent.strip().upper() == "FREI" else int(args.parent)
243
+ else:
244
+ parent = fetch_parent(issue)
245
+
246
+ is_anchor = fetch_is_anchor(issue) if parent is None else False
247
+ violations = check_pr_body(body, issue, parent, is_anchor=is_anchor)
248
+ kind = ("Anker-Slice" if parent is not None
249
+ else "Wave-PR" if is_anchor else "Leaf")
250
+ log(f"branch={branch} issue={issue} parent={parent} kind={kind} "
251
+ f"violations={len(violations)}")
252
+
253
+ if violations:
254
+ print(f"[FAIL] pr-body-check #{issue} ({kind}, parent={parent}):")
255
+ for v in violations:
256
+ print(f" - {v}")
257
+ return 1
258
+ print(f"[OK] pr-body-check #{issue} ({kind}) — Body-Konventionen erfüllt.")
259
+ return 0
260
+
261
+
262
+ if __name__ == "__main__":
263
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """program_graph.py — public assembly + report layer for the Programm-Graph.
3
+
4
+ board-sync.py's `validate-graph` command imports from this one module. The
5
+ implementation is split by concern across three cohesive files (a single file
6
+ would exceed the 300-line size gate):
7
+
8
+ - program_graph_parse.py — parse the Program-PRD body into dataclasses +
9
+ the Wellenplan-table / Slice-block renderers
10
+ (parser↔renderer roundtrip pairs).
11
+ - program_graph_validate.py — the 8 pure validation axes over the parsed graph.
12
+ - this file — the `GraphReport` model, the `validate_program_graph`
13
+ orchestrator wiring the axes together, the counted
14
+ `render_report`, plus a flat re-export of the
15
+ parse/validate symbols consumers use.
16
+
17
+ PURE — no gh / no I/O / no board_config (mirrors anchor_table.py's purity
18
+ contract). The CLI handler in board-sync.py reads `phase_options`
19
+ defensively from the board profile and passes it in (CR#1).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Optional
25
+
26
+ from program_graph_parse import ( # noqa: F401
27
+ ProgramGraph,
28
+ SliceBlock,
29
+ WaveRow,
30
+ parse_phase_gates,
31
+ parse_plan_revision,
32
+ parse_program_prd,
33
+ parse_scope_items,
34
+ parse_slice_blocks,
35
+ parse_wellenplan_table,
36
+ render_slice_block,
37
+ render_wellenplan_table,
38
+ )
39
+ from program_graph_validate import ( # noqa: F401
40
+ GATE_TYPES,
41
+ check_capacity,
42
+ check_phase_options,
43
+ check_revision_coherence,
44
+ check_rollup,
45
+ check_scope_coverage,
46
+ find_backward_refs,
47
+ find_cycles,
48
+ find_duplicate_slice_ids,
49
+ find_gate_dependents_outside_wave,
50
+ find_unknown_refs,
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class GraphReport:
56
+ scope_total: int
57
+ scope_covered: int
58
+ scope_gaps: list[str]
59
+ rollup_gaps: list[str]
60
+ cycles: list[list[str]]
61
+ backward_refs: list[str]
62
+ gate_warnings: list[str]
63
+ capacity_errors: list[str]
64
+ phase_errors: list[str]
65
+ revision_mismatches: list[str]
66
+ duplicate_id_errors: list[str]
67
+
68
+ @property
69
+ def cycle_messages(self) -> list[str]:
70
+ return [f"Zyklus: {' → '.join(c)}" for c in self.cycles]
71
+
72
+ @property
73
+ def errors(self) -> list[str]:
74
+ """Blocking findings — Vorschau-Gate publish must not proceed."""
75
+ return [*self.cycle_messages, *self.backward_refs, *self.capacity_errors,
76
+ *self.phase_errors, *self.revision_mismatches, *self.duplicate_id_errors]
77
+
78
+ @property
79
+ def warnings(self) -> list[str]:
80
+ """Advisory findings — shown + counted, but a human decides."""
81
+ return [*self.gate_warnings, *self.rollup_gaps, *self.scope_gaps]
82
+
83
+ @property
84
+ def blocking(self) -> bool:
85
+ return bool(self.errors)
86
+
87
+
88
+ def validate_program_graph(prd_body: str, *, phase_options: Optional[list[str]] = None,
89
+ max_children: int = 100,
90
+ stub_revisions: Optional[list[dict]] = None) -> GraphReport:
91
+ """The single entry point: parse a Program-PRD body and run all 8 axes."""
92
+ graph = parse_program_prd(prd_body)
93
+ covered, total, scope_gaps = check_scope_coverage(graph)
94
+ return GraphReport(
95
+ scope_total=total,
96
+ scope_covered=covered,
97
+ scope_gaps=scope_gaps,
98
+ rollup_gaps=check_rollup(graph),
99
+ cycles=find_cycles(graph.slices),
100
+ backward_refs=[*find_backward_refs(graph.slices), *find_unknown_refs(graph.slices)],
101
+ gate_warnings=find_gate_dependents_outside_wave(graph.slices),
102
+ capacity_errors=check_capacity(graph.waves, max_children),
103
+ phase_errors=check_phase_options(graph, phase_options),
104
+ revision_mismatches=check_revision_coherence(graph.plan_revision, stub_revisions),
105
+ duplicate_id_errors=find_duplicate_slice_ids(graph.slices),
106
+ )
107
+
108
+
109
+ def render_report(report: GraphReport) -> str:
110
+ """The counted Vorschau-Gate summary line + detail lines (AC1)."""
111
+ summary = (
112
+ f"Scope-Abdeckung {report.scope_covered} von {report.scope_total} · "
113
+ f"Rollup-Kette {'✓' if not report.rollup_gaps else 'Lücken'} · "
114
+ f"zyklenfrei {'✓' if not report.cycles else '✗'} · "
115
+ f"Kapazität {'✓' if not report.capacity_errors else '✗'} · "
116
+ f"Phasen-Optionen {'✓' if not report.phase_errors else '✗'}"
117
+ )
118
+ lines = [summary]
119
+ for msg in report.errors:
120
+ lines.append(f" FEHLER: {msg}")
121
+ for msg in report.warnings:
122
+ lines.append(f" WARNUNG: {msg}")
123
+ if not report.errors and not report.warnings:
124
+ lines.append(" keine weiteren Befunde")
125
+ return "\n".join(lines)
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ """program_graph_parse.py — pure parser for the Programm-PRD grammar.
3
+
4
+ Parses a Program-PRD issue body (per `.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md`)
5
+ into structured dataclasses: plan_revision, Scope-Items (S1, S2, …), the Wellenplan
6
+ table (Welle/Status/Name/Phase/Slices/Gate/covers), the Phasen-Gates checklist,
7
+ and the per-Slice metadata blocks (per
8
+ `.claude/skills/to-waves/SLICE-METADATA-FORMAT.md`).
9
+
10
+ PURE — no gh / no I/O / no board_config. Reuses the generic pipe-table primitives
11
+ from anchor_table.py (a different table GRAMMAR — different columns, different
12
+ node kinds — but the same markdown mechanics, so the parse/render primitives are
13
+ shared rather than re-implemented design decision).
14
+
15
+ The Wellenplan-table renderer lives here too (parser↔renderer roundtrip pair,
16
+ consumed later by `program-sync`).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from typing import Optional
23
+
24
+ from anchor_table import col_index, parse_pipe_table, render_pipe_table
25
+
26
+ PLAN_REV_RE = re.compile(r"\*\*plan_revision:\*\*\s*r(\d+)\b")
27
+ _H2_RE = re.compile(r"^##\s+(.+?)\s*$")
28
+ SCOPE_ITEM_RE = re.compile(r"^-\s+\*\*(S\d+):\*\*\s*(.+)$")
29
+ PHASE_GATE_RE = re.compile(r"^-\s+\[([ xX])\]\s*(P\d+):\s*(.+)$")
30
+
31
+ WELLENPLAN_START = "<!-- wellenplan:start -->"
32
+ WELLENPLAN_END = "<!-- wellenplan:end -->"
33
+
34
+ WELLENPLAN_HEADERS = ["Welle", "Status", "Issue", "Name", "Phase", "Slices", "Gate", "covers"]
35
+ _ENABLER = "enabler"
36
+ # Tokens that mean "no value" in an optional cell — shared by the Wellenplan phase
37
+ # and covers cells and the Slice-block phase/blocked_by fields so `—` normalizes
38
+ # consistently everywhere.
39
+ _NONE_TOKENS = {"none", "", "—", "-"}
40
+
41
+
42
+ def _none_if_blank(value: str) -> Optional[str]:
43
+ """The trimmed value, or None if it is one of the `_NONE_TOKENS` sentinels."""
44
+ v = (value or "").strip()
45
+ return None if v.lower() in _NONE_TOKENS else v
46
+
47
+
48
+ @dataclass
49
+ class WaveRow:
50
+ number: int
51
+ name: str
52
+ phase: Optional[str]
53
+ slice_ids: list[str]
54
+ gate: str
55
+ covers: list[str]
56
+ is_enabler: bool = False
57
+ # Volatile, board-derived — ⬜ (not started) at authoring, regenerated
58
+ # monotonically by `program-sync` (see program_sync.refresh_wave_status).
59
+ # Not a validate-graph input (no axis reads it) and — unlike the semantic
60
+ # columns above — TOLERANT-optional on parse (docs/adr/0054 Folge-Arbeit:
61
+ # one Wellenplan grammar, this is a column within it, not a second table).
62
+ status: str = "⬜"
63
+ # The promoted Wave-Anchor issue number (`#<n>` cell) — the navigation link
64
+ # from the program table down to the wave. Machine-written (to-waves
65
+ # publish / program-sync), `—` pre-publish; TOLERANT-optional like status,
66
+ # and identity once set (program-sync fills but never overwrites it).
67
+ issue: Optional[int] = None
68
+
69
+
70
+ @dataclass
71
+ class SliceBlock:
72
+ local_id: str
73
+ title: str
74
+ wave: Optional[int]
75
+ phase: Optional[str]
76
+ area: Optional[str]
77
+ gate: str
78
+ blocked_by: list[str] = field(default_factory=list)
79
+
80
+
81
+ @dataclass
82
+ class ProgramGraph:
83
+ plan_revision: Optional[int]
84
+ scope_items: dict[str, str]
85
+ waves: list[WaveRow]
86
+ slices: list[SliceBlock]
87
+ phase_gates: dict[str, str]
88
+
89
+
90
+ def parse_plan_revision(body: str) -> Optional[int]:
91
+ """The PRD's top-level `**plan_revision:** rN`, or None if absent/malformed."""
92
+ m = PLAN_REV_RE.search(body or "")
93
+ return int(m.group(1)) if m else None
94
+
95
+
96
+ def _section_lines(body: str, heading: str) -> list[str]:
97
+ """Lines of a `## <heading>` section, up to (excl.) the next `##` heading."""
98
+ lines = (body or "").splitlines()
99
+ start = None
100
+ for i, ln in enumerate(lines):
101
+ m = _H2_RE.match(ln)
102
+ if m and m.group(1).strip() == heading:
103
+ start = i + 1
104
+ break
105
+ if start is None:
106
+ return []
107
+ end = len(lines)
108
+ for j in range(start, len(lines)):
109
+ if _H2_RE.match(lines[j]):
110
+ end = j
111
+ break
112
+ return lines[start:end]
113
+
114
+
115
+ def parse_scope_items(body: str) -> dict[str, str]:
116
+ """`{S1: "description", ...}` from the `## Scope` chapter's `- **S1:** …` list."""
117
+ items: dict[str, str] = {}
118
+ for ln in _section_lines(body, "Scope"):
119
+ m = SCOPE_ITEM_RE.match(ln.strip())
120
+ if m:
121
+ items[m.group(1)] = m.group(2).strip()
122
+ return items
123
+
124
+
125
+ def parse_phase_gates(body: str) -> dict[str, str]:
126
+ """`{P1: "criterion text", ...}` from the `## Phasen-Gates` checklist."""
127
+ gates: dict[str, str] = {}
128
+ for ln in _section_lines(body, "Phasen-Gates"):
129
+ m = PHASE_GATE_RE.match(ln.strip())
130
+ if m:
131
+ gates[m.group(2)] = m.group(3).strip()
132
+ return gates
133
+
134
+
135
+ def _split_csv(cell: str) -> list[str]:
136
+ return [t.strip() for t in (cell or "").split(",") if t.strip()]
137
+
138
+
139
+ def _parse_covers_cell(cell: str) -> tuple[list[str], bool]:
140
+ tokens = _split_csv(cell)
141
+ if len(tokens) == 1 and tokens[0].lower() == _ENABLER:
142
+ return [], True
143
+ # `—`/`-`/`none` tokens are the same "no value" sentinels as the phase and
144
+ # blocked_by cells — an all-sentinel covers cell must normalize to [], not
145
+ # a phantom covers=['—'] that then fools check_scope_coverage (Bug 1).
146
+ tokens = [t for t in tokens if t.lower() not in _NONE_TOKENS]
147
+ return tokens, False
148
+
149
+
150
+ def parse_wellenplan_table(body: str) -> list[WaveRow]:
151
+ """The Wellenplan table between the `wellenplan:start/end` markers. `Phase` is an
152
+ optional column (Phasen sind optional) — absent column ⇒ every row's phase=None.
153
+ `Status` is likewise optional, but for a different reason: it is volatile,
154
+ board-derived state (never authored by hand), so an absent column or blank
155
+ cell ⇒ every row's status defaults to `⬜` rather than raising — same
156
+ tolerant treatment as Phase, distinct from the required semantic columns."""
157
+ if WELLENPLAN_START not in body or WELLENPLAN_END not in body:
158
+ return []
159
+ inner = body.split(WELLENPLAN_START, 1)[1].split(WELLENPLAN_END, 1)[0]
160
+ headers, rows = parse_pipe_table(inner.splitlines())
161
+ if not headers:
162
+ return []
163
+ idx = {name: col_index(headers, name) for name in WELLENPLAN_HEADERS}
164
+ i_phase = idx["Phase"] # optional column — may be None
165
+ i_status = idx["Status"] # optional column — may be None, defaults to ⬜
166
+ i_issue = idx["Issue"] # optional column — may be None, defaults to no link
167
+ missing = [name for name in WELLENPLAN_HEADERS
168
+ if name not in ("Phase", "Status", "Issue") and idx[name] is None]
169
+ if missing:
170
+ raise ValueError(
171
+ f"Wellenplan table missing column(s) {', '.join(missing)} "
172
+ f"(headers: {headers})")
173
+ out: list[WaveRow] = []
174
+ for row in rows:
175
+ cell = lambda i: row[i].strip() if i is not None and i < len(row) else ""
176
+ covers, is_enabler = _parse_covers_cell(cell(idx["covers"]))
177
+ issue_m = re.search(r"#(\d+)", cell(i_issue))
178
+ out.append(WaveRow(
179
+ number=int(cell(idx["Welle"])),
180
+ name=cell(idx["Name"]),
181
+ phase=_none_if_blank(cell(i_phase)),
182
+ slice_ids=_split_csv(cell(idx["Slices"])),
183
+ gate=cell(idx["Gate"]) or "—",
184
+ covers=covers,
185
+ is_enabler=is_enabler,
186
+ status=cell(i_status) or "⬜",
187
+ issue=int(issue_m.group(1)) if issue_m else None,
188
+ ))
189
+ return out
190
+
191
+
192
+ def render_wellenplan_table(waves: list[WaveRow], *, include_phase: bool = True) -> str:
193
+ """Render the Wellenplan table (markers + pipe table) — inverse of
194
+ `parse_wellenplan_table` (roundtrip pair). `Status` and `Issue` are always
195
+ emitted (both always present, unlike `Phase` which is toggled by
196
+ `include_phase`) — the two machine-written columns `program-sync` rewrites
197
+ (: `Issue` is the navigation link down to the promoted Wave-Anchor)."""
198
+ headers = [h for h in WELLENPLAN_HEADERS if include_phase or h != "Phase"]
199
+ rows = []
200
+ for w in waves:
201
+ covers_cell = _ENABLER if w.is_enabler else ", ".join(w.covers)
202
+ cells = {
203
+ "Welle": str(w.number),
204
+ "Status": w.status,
205
+ "Issue": f"#{w.issue}" if w.issue is not None else "—",
206
+ "Name": w.name,
207
+ "Phase": w.phase or "",
208
+ "Slices": ", ".join(w.slice_ids),
209
+ "Gate": w.gate,
210
+ "covers": covers_cell,
211
+ }
212
+ rows.append([cells[h] for h in headers])
213
+ table = render_pipe_table(headers, rows)
214
+ return f"{WELLENPLAN_START}\n{table}\n{WELLENPLAN_END}"
215
+
216
+
217
+ SLICE_HEADING_RE = re.compile(r"^####\s+(\S+)\s+—\s+(.+)$")
218
+ META_FIELD_RE = re.compile(r"^<!--\s*(wave|phase|area|gate|blocked_by):\s*(.*?)\s*-->$")
219
+
220
+
221
+ def parse_slice_blocks(body: str) -> list[SliceBlock]:
222
+ """The `#### <local-id> — <Title>` sections of `## Slices`, each carrying a
223
+ 5-field HTML-comment metadata block (SLICE-METADATA-FORMAT.md)."""
224
+ lines = (body or "").splitlines()
225
+ heads = []
226
+ for i, ln in enumerate(lines):
227
+ m = SLICE_HEADING_RE.match(ln.strip())
228
+ if m:
229
+ heads.append((i, m.group(1), m.group(2).strip()))
230
+ blocks: list[SliceBlock] = []
231
+ for idx, (start, local_id, title) in enumerate(heads):
232
+ end = heads[idx + 1][0] if idx + 1 < len(heads) else len(lines)
233
+ fields: dict[str, str] = {}
234
+ for ln in lines[start + 1:end]:
235
+ m = META_FIELD_RE.match(ln.strip())
236
+ if m:
237
+ fields[m.group(1)] = m.group(2)
238
+ wave_raw = fields.get("wave", "").strip()
239
+ blocked_raw = fields.get("blocked_by", "none").strip()
240
+ blocked_by = [] if blocked_raw.lower() in _NONE_TOKENS else _split_csv(blocked_raw)
241
+ blocks.append(SliceBlock(
242
+ local_id=local_id,
243
+ title=title,
244
+ wave=int(wave_raw) if wave_raw.isdigit() else None,
245
+ phase=_none_if_blank(fields.get("phase", "")),
246
+ area=(fields.get("area") or "").strip() or None,
247
+ gate=(fields.get("gate") or "").strip() or "—",
248
+ blocked_by=blocked_by,
249
+ ))
250
+ return blocks
251
+
252
+
253
+ def render_slice_block(s: SliceBlock) -> str:
254
+ """Render one Slice section — inverse of the per-block parse in
255
+ `parse_slice_blocks` (roundtrip pair)."""
256
+ blocked = "none" if not s.blocked_by else ", ".join(s.blocked_by)
257
+ return (
258
+ f"#### {s.local_id} — {s.title}\n"
259
+ f"<!-- wave: {s.wave if s.wave is not None else ''} -->\n"
260
+ f"<!-- phase: {s.phase or '—'} -->\n"
261
+ f"<!-- area: {s.area or ''} -->\n"
262
+ f"<!-- gate: {s.gate} -->\n"
263
+ f"<!-- blocked_by: {blocked} -->\n"
264
+ )
265
+
266
+
267
+ def parse_program_prd(body: str) -> ProgramGraph:
268
+ """The full ProgramGraph parsed from a Program-PRD issue body."""
269
+ return ProgramGraph(
270
+ plan_revision=parse_plan_revision(body),
271
+ scope_items=parse_scope_items(body),
272
+ waves=parse_wellenplan_table(body),
273
+ slices=parse_slice_blocks(body),
274
+ phase_gates=parse_phase_gates(body),
275
+ )