@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,516 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ execute-ready-check.py — single source of truth for "execute-ready" graph
4
+ coherence.
5
+
6
+ Four callers share this one checker so the parse/coherence rules live + are
7
+ tested ONCE:
8
+ - .claude/hooks/drift-guard.py --mode handoff (blocks handoff on deny_recommended)
9
+ - to-issues §7 exit --mode audit (non-blocking warn — as in 1d)
10
+ - grill-with-docs Re-Grill --mode audit
11
+ - wrapup Land-Reconcile --mode audit
12
+
13
+ Two separate axes (Codex R1/R2):
14
+ - graph_coherent : every node in the rooted local graph is well-formed
15
+ (Anker: plan_revision + no bucket/ready-for-agent;
16
+ child/leaf: exactly one bucket, plan_revision coherent).
17
+ - target_buildable : only for the handoff target — AFK = buildable,
18
+ HITL = valid-but-not-buildable.
19
+ deny_recommended = !graph_coherent
20
+ OR (mode==handoff AND intent==build AND target leaf AND !buildable).
21
+ - shape_warnings : provenance-NEUTRAL anchor form check (template
22
+ sections + body header). audit-only, anchor-only, and
23
+ NEVER part of graph_coherent/deny_recommended — a loud
24
+ nudge for output-uniformity, not a gate.
25
+
26
+ Rooted local graph = handoff-target → its native parent (lift) → that parent's
27
+ direct children. NOT a board-wide scan. Atomar leaf (no parent) = its own node only.
28
+
29
+ gh access is a thin, mockable layer (reuses scripts/board-sync.py parent-of /
30
+ children-of). The pure functions below carry the logic + the unit tests.
31
+
32
+ Usage:
33
+ execute-ready-check.py --issue <n> --mode handoff|audit [--intent build|grill] [--json]
34
+ Exit 0 always (it is a checker, not a gate); the hook decides blocking.
35
+
36
+ Marker conventions (CANONICAL — the skills cross-reference this block; all are
37
+ grep-bar HTML comments living in issue/PR/handoff bodies):
38
+ <!-- guard-ack: #<n> r<N> reason:<text> by-user --> deliberate handoff override (drift-guard.py)
39
+ <!-- guard-legacy --> grandfathered alt-anchor → warn, not block
40
+ <!-- handoff-intent: build|grill --> handoff intent (else inferred from a /grill cmd)
41
+ <!-- final-cut-depends-on: #<n> --> leaf's final cut hangs on #n; #n CLOSED → block
42
+ <!-- annahme-drift: {"target":"#<n>",...} --> drift propagation — consumed by wrapup Step 5e
43
+ (NOT parsed here)
44
+ <!-- prd: program --> Programm-PRD marker (node_kind.py) — this
45
+ target roots at itself, never lifts
46
+
47
+ Audit log: .claude/logs/execute-ready-check.log
48
+ """
49
+ import argparse
50
+ import json
51
+ import re
52
+ import subprocess
53
+ import sys
54
+ from pathlib import Path
55
+
56
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
57
+ from board_config import ConfigError, load_board_config # noqa: E402
58
+ from node_kind import ANCHOR, LEAF, PROGRAM, ROOT_KINDS, WAVE_STUB, classify_node # noqa: E402,F401
59
+ # Native issue dependencies are the blocking SSOT; the `## Blocked by`
60
+ # body section is only their machine-written mirror (issue_deps.py, shared with
61
+ # board-sync.py). Drift semantics: warn in audit, deny at handoff.
62
+ from issue_deps import open_blocker_numbers, parse_blocked_by_numbers # noqa: E402
63
+
64
+ # Project-specific label + heading come from the board profile (no inline
65
+ # constants → published kit stays project-neutral).
66
+ try:
67
+ _CFG = load_board_config()
68
+ except ConfigError as exc:
69
+ print(f"[FAIL] execute-ready-check: Board-Profil nicht verfügbar — {exc}", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+ LOG_DIR = Path(".claude/logs")
73
+ LOG_NAME = "execute-ready-check"
74
+
75
+ # --- markers / patterns -----------------------------------------------------
76
+ # Broad: a line that *intends* to be the plan_revision marker (for malformed detection).
77
+ PLAN_REV_BROAD = re.compile(r"\*\*plan_revision:\*\*")
78
+ # Strict: a well-formed `**plan_revision:** r<N>`.
79
+ PLAN_REV_STRICT = re.compile(r"\*\*plan_revision:\*\*\s*r(\d+)\b")
80
+ HEADING_RE = re.compile(r"^#{1,6}\s")
81
+ VOR_BAU_RE = re.compile(r"^#{1,6}\s*" + re.escape(_CFG["headings"]["vorBau"]), re.MULTILINE)
82
+ FINAL_CUT_RE = re.compile(r"<!--\s*final-cut-depends-on:\s*#?(\d+)\s*-->")
83
+ GUARD_LEGACY_RE = re.compile(r"<!--\s*guard-legacy\s*-->")
84
+ HANDOFF_INTENT_RE = re.compile(r"<!--\s*handoff-intent:\s*(build|grill)\s*-->")
85
+ GRILL_CMD_RE = re.compile(r"/grill(?:-me|-with-docs)?(?:-codex)?\b")
86
+ READY_LABEL = _CFG["labels"]["readyForAgent"]
87
+ # Read defensively: `labels.programType` (a filterable Programm-PRD label) is
88
+ # added by a LATER slice — this checker must keep working before that
89
+ # key exists, so `program` classification is 100% marker-driven (node_kind.py).
90
+ CLUSTER_TYPE_LABEL = _CFG["labels"].get("clusterType")
91
+ WAVE_STUB_LABEL = _CFG["labels"].get("waveStub")
92
+
93
+
94
+ def classify(node: dict) -> str:
95
+ """Node kind (program|anchor|wave_stub|leaf) bound to this repo's board
96
+ profile — see node_kind.py for the precedence rules."""
97
+ return classify_node(node, cluster_type_label=CLUSTER_TYPE_LABEL,
98
+ wave_stub_label=WAVE_STUB_LABEL)
99
+
100
+
101
+ # NOTE: log() + _run() below are intentional small isolation copies of
102
+ # _hook_utils.{log,run_with_status} — scripts/ tools (cf. board-sync.py) do not
103
+ # import from .claude/hooks/. Keep them thin; do not add a third copy elsewhere.
104
+ def log(msg: str) -> None:
105
+ try:
106
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
107
+ from datetime import datetime
108
+ with (LOG_DIR / f"{LOG_NAME}.log").open("a", encoding="utf-8") as f:
109
+ f.write(f"{datetime.now().isoformat(timespec='seconds')} {msg}\n")
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ # --- pure parsers -----------------------------------------------------------
115
+ def parse_plan_revision(body: str):
116
+ """Return (rev:int|None, status). status ∈ {ok, missing, malformed, multiple, misplaced}.
117
+
118
+ 'Top' = after leading metadata HTML comments, before the first Markdown
119
+ heading. Blockquote lines (quoted old bodies) are ignored so a stale marker
120
+ inside a `>` quote cannot fake coherence.
121
+ """
122
+ if not body:
123
+ return None, "missing"
124
+ lines = body.splitlines()
125
+ marker_idx = [
126
+ i for i, ln in enumerate(lines)
127
+ if not ln.lstrip().startswith(">") and PLAN_REV_BROAD.search(ln)
128
+ ]
129
+ if not marker_idx:
130
+ return None, "missing"
131
+ if len(marker_idx) > 1:
132
+ return None, "multiple"
133
+ i = marker_idx[0]
134
+ m = PLAN_REV_STRICT.search(lines[i])
135
+ if not m:
136
+ return None, "malformed"
137
+ first_heading = next((j for j, ln in enumerate(lines) if HEADING_RE.match(ln)), len(lines))
138
+ if i > first_heading:
139
+ return int(m.group(1)), "misplaced"
140
+ return int(m.group(1)), "ok"
141
+
142
+
143
+ def parse_bucket(labels, body: str) -> str:
144
+ """afk | hitl | ambiguous. AFK = ready-for-agent + no Vor-Bau; HITL = no
145
+ ready-for-agent + Vor-Bau present. Both or neither → ambiguous."""
146
+ has_ready = READY_LABEL in (labels or [])
147
+ has_vorbau = bool(VOR_BAU_RE.search(body or ""))
148
+ if has_ready and not has_vorbau:
149
+ return "afk"
150
+ if has_vorbau and not has_ready:
151
+ return "hitl"
152
+ return "ambiguous"
153
+
154
+
155
+ def infer_intent(content: str, marker_intent: str = None) -> str:
156
+ """build | grill. Explicit handoff-intent marker wins; else infer from a
157
+ grill command in the handoff content; else default build."""
158
+ if marker_intent in ("build", "grill"):
159
+ return marker_intent
160
+ if content:
161
+ mk = HANDOFF_INTENT_RE.search(content)
162
+ if mk:
163
+ return mk.group(1)
164
+ if GRILL_CMD_RE.search(content):
165
+ return "grill"
166
+ return "build"
167
+
168
+
169
+ def parse_final_cut_depends(body: str):
170
+ m = FINAL_CUT_RE.search(body or "")
171
+ return int(m.group(1)) if m else None
172
+
173
+
174
+ def is_legacy(body: str) -> bool:
175
+ # Marker-only by design. Date-based grandfathering (createdAt < enforcement
176
+ # date) is intentionally NOT implemented — the explicit `<!-- guard-legacy -->`
177
+ # marker is the single grandfather path (alt-anchors like get tagged once).
178
+ return bool(GUARD_LEGACY_RE.search(body or ""))
179
+
180
+
181
+ # --- anchor shape audit (non-blocking) -------------------------------
182
+ # Provenance-NEUTRAL form check: does a promoted anchor carry the uniform
183
+ # wave-anchor-template shape? Emitted ONLY as shape_warnings in --mode audit;
184
+ # NEVER fed into violations / graph_coherent / deny_recommended. A missing
185
+ # section is a loud nudge, never a handoff block — folding it into deny would
186
+ # recreate exactly the provenance-harness rejected. The hard block stays
187
+ # bucket + coherence only.
188
+ #
189
+ # The SSOT template (docs/agents/wave-anchor-template.md) went English-only
190
+ # — `Welle`/`Herkunft`/`Entscheidungen` stay accepted so EXISTING
191
+ # German anchors keep auditing clean, and `Wave`/`Origin`/`Decisions` are
192
+ # accepted alongside so anchors built from the now-English template do too.
193
+ _WAVE_HEADER_RE = re.compile(r"\*\*\s*(?:Welle|Wave)\s+\d+\s*[—–-]", re.IGNORECASE)
194
+ # `## Herkunft`/`## Origin` (new) or the legacy `## Cluster-Herkunft` all satisfy the check.
195
+ _SHAPE_SECTIONS = (
196
+ ("Herkunft/Origin", re.compile(r"^#{1,6}\s*(?:(?:Cluster-)?Herkunft|Origin)\b", re.MULTILINE)),
197
+ ("Entscheidungen/Decisions", re.compile(r"^#{1,6}\s*(?:Entscheidungen|Decisions)\b", re.MULTILINE)),
198
+ ("Slices", re.compile(r"^#{1,6}\s*Slices\b", re.MULTILINE)),
199
+ )
200
+
201
+
202
+ def evaluate_anchor_shape(body: str) -> list[str]:
203
+ """Non-blocking form check for a promoted anchor body.
204
+
205
+ Returns human-readable shape warnings (missing template sections / body
206
+ header). NEVER contributes to violations — uniformity is a loud nudge, not
207
+ a gate.
208
+ """
209
+ warnings = []
210
+ if not _WAVE_HEADER_RE.search(body or ""):
211
+ warnings.append("anchor body header `**Wave N — …**` missing")
212
+ for name, rx in _SHAPE_SECTIONS:
213
+ if not rx.search(body or ""):
214
+ warnings.append(f"anchor section `## {name}` missing")
215
+ return warnings
216
+
217
+
218
+ # --- pure coherence ---------------------------------------------------------
219
+ def evaluate_graph(target, parent=None, siblings=None, *, mode="handoff",
220
+ intent="build", closed_lookup=None, truncated=False,
221
+ root_kind=None, native_blockers=None) -> dict:
222
+ """Evaluate the rooted local graph. Nodes are dicts {number, body, labels}.
223
+
224
+ parent=None → atomar leaf (graph = [target]).
225
+ parent given → target's local root + its children (siblings, incl.
226
+ target if target is a child of that root).
227
+ root_kind=None → target was LIFTED to `parent` (a real, different
228
+ parent) or is the atomar leaf above — target_kind
229
+ resolves to "leaf".
230
+ root_kind=ANCHOR/PROGRAM → target IS its own root (`parent` == `target`,
231
+ `siblings` == target's own children) — an anchor or
232
+ Programm-PRD never lifts to a native parent it might
233
+ also have.
234
+
235
+ Each CHILD in `siblings` is classified independently (`classify_node`):
236
+ only LEAF children go through the bucket + plan_revision-match checks —
237
+ an anchor/wave_stub/program child carries neither a bucket nor a shared
238
+ plan_revision domain with its parent, and is audited on its own terms
239
+ when IT is the checker's target (rooted at itself via ROOT_KINDS).
240
+ """
241
+ violations = []
242
+ closed_lookup = closed_lookup or {}
243
+ grandfathered = None # set to the anchor # when a legacy-tagged anchor grandfathers its graph
244
+
245
+ def check_rev_ok(node, kind_label, suppress_rev_status=False):
246
+ rev, st = parse_plan_revision(node["body"])
247
+ if st != "ok" and not is_legacy(node["body"]) and not suppress_rev_status:
248
+ violations.append(f"#{node['number']}{kind_label}: plan_revision {st}")
249
+ return rev, st
250
+
251
+ if parent is None:
252
+ # atomar leaf
253
+ check_rev_ok(target, "")
254
+ if parse_bucket(target["labels"], target["body"]) == "ambiguous" \
255
+ and not is_legacy(target["body"]):
256
+ violations.append(f"#{target['number']}: ambiguous bucket (ready-for-agent vs Vor Bau zu klären)")
257
+ target_kind = LEAF
258
+ else:
259
+ # Anchor tagged <!-- guard-legacy --> grandfathers the whole rooted graph
260
+ # (Q4=A: tag once → free). Constrained: pre-convention classes
261
+ # (plan_revision status, anchor-rev mismatch) are suppressed graph-wide;
262
+ # ambiguous-bucket only for CLOSED children — an OPEN child's bucket and
263
+ # the structural ready-on-anchor check stay live (new incoherence visible).
264
+ anchor_legacy = is_legacy(parent["body"])
265
+ if anchor_legacy:
266
+ grandfathered = parent["number"]
267
+ arev, ast = check_rev_ok(parent, " (anchor)")
268
+ if READY_LABEL in (parent["labels"] or []):
269
+ violations.append(f"#{parent['number']} (anchor): ready-for-agent on an anchor")
270
+ for child in (siblings or []):
271
+ if classify(child) != LEAF:
272
+ # anchor/wave_stub/program sibling — no bucket, no shared
273
+ # plan_revision domain with `parent`.
274
+ continue
275
+ crev, cst = check_rev_ok(child, "", suppress_rev_status=anchor_legacy)
276
+ legacy = is_legacy(child["body"])
277
+ child_closed = (child.get("state") or "").upper() == "CLOSED"
278
+ suppress_bucket = legacy or (anchor_legacy and child_closed)
279
+ if parse_bucket(child["labels"], child["body"]) == "ambiguous" and not suppress_bucket:
280
+ violations.append(f"#{child['number']}: ambiguous bucket")
281
+ if cst == "ok" and ast == "ok" and crev != arev and not legacy and not anchor_legacy:
282
+ violations.append(f"#{child['number']}: plan_revision r{crev} != anchor r{arev}")
283
+ target_kind = root_kind if root_kind in ROOT_KINDS else LEAF
284
+
285
+ # Fix B (b): final-cut dependency closed without resolution
286
+ fc = parse_final_cut_depends(target["body"])
287
+ if fc is not None:
288
+ fc_state = closed_lookup.get(fc)
289
+ if fc_state == "closed":
290
+ violations.append(f"#{target['number']}: final-cut depends on #{fc} which is CLOSED")
291
+ elif fc_state == "unresolved":
292
+ # gh could not resolve the dependency (network/auth) — a silent
293
+ # "open" default here would let a stale/unverifiable final-cut
294
+ # dependency slip an unbuilt handoff through.
295
+ violations.append(
296
+ f"#{target['number']}: final-cut dependency #{fc} could not be "
297
+ f"verified via gh (network/auth) — fail-closed")
298
+
299
+ if truncated:
300
+ violations.append("graph too large for guard (>100 children) — cannot prove completeness")
301
+
302
+ # Native blocking SSOT. native_blockers ∈ None (not instrumented —
303
+ # pure-call back-compat) | "unresolved" (gh failed → fail-closed
304
+ # pattern) | list of {number, state, …}. Being blocked is NOT incoherence:
305
+ # it gates only a BUILD handoff of a leaf (audit shows it informationally).
306
+ # Mirror drift: warn in audit, violation (deny) only at handoff.
307
+ open_blockers: list = []
308
+ drift_warnings: list = []
309
+ if native_blockers == "unresolved":
310
+ violations.append(
311
+ f"#{target['number']}: native blocked-by edges could not be verified "
312
+ f"via gh (network/auth) — fail-closed")
313
+ elif native_blockers is not None:
314
+ open_blockers = open_blocker_numbers(native_blockers)
315
+ mirror = set(parse_blocked_by_numbers(target["body"]))
316
+ native = {b["number"] for b in native_blockers}
317
+ if mirror != native:
318
+ msg = (f"#{target['number']}: `## Blocked by` mirror {sorted(mirror)} != "
319
+ f"native dependencies {sorted(native)} — API is the truth "
320
+ f"(repair: scripts/board-sync.py dep-add/dep-remove)")
321
+ if mode == "handoff":
322
+ violations.append(msg)
323
+ else:
324
+ drift_warnings.append(msg)
325
+
326
+ if target_kind in ROOT_KINDS:
327
+ target_buildable = True
328
+ else:
329
+ target_buildable = parse_bucket(target["labels"], target["body"]) == "afk"
330
+
331
+ graph_coherent = len(violations) == 0
332
+ deny_recommended = (not graph_coherent) or (
333
+ mode == "handoff" and intent == "build" and target_kind == LEAF
334
+ and (not target_buildable or bool(open_blockers))
335
+ )
336
+ # Non-blocking, audit-only, anchor-only (a Programm-PRD's body follows
337
+ # PROGRAM-PRD-FORMAT, not wave-anchor-template — the shape check would
338
+ # only produce spurious nudges there). Intentionally NOT part of
339
+ # graph_coherent/deny_recommended (see evaluate_anchor_shape docstring).
340
+ shape_warnings = (
341
+ evaluate_anchor_shape(target["body"])
342
+ if mode == "audit" and target_kind == ANCHOR
343
+ else []
344
+ )
345
+ return {
346
+ "graph_coherent": graph_coherent,
347
+ "target_buildable": target_buildable,
348
+ "deny_recommended": deny_recommended,
349
+ "violations": violations,
350
+ "grandfathered": grandfathered,
351
+ "shape_warnings": shape_warnings,
352
+ "open_blockers": open_blockers,
353
+ "drift_warnings": drift_warnings,
354
+ }
355
+
356
+
357
+ # --- thin gh layer (mocked in tests via the functions above) ----------------
358
+ def _run(cmd, timeout=15):
359
+ try:
360
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
361
+ return r.returncode, r.stdout.strip()
362
+ except Exception:
363
+ return -1, ""
364
+
365
+
366
+ def fetch_issue(number: int):
367
+ """{number, body, labels:[str], state} via `gh issue view`, or None on failure."""
368
+ rc, out = _run(["gh", "issue", "view", str(number), "--json",
369
+ "number,body,labels,state"])
370
+ if rc != 0 or not out:
371
+ return None
372
+ try:
373
+ d = json.loads(out)
374
+ except json.JSONDecodeError:
375
+ return None
376
+ return {
377
+ "number": d.get("number"),
378
+ "body": d.get("body") or "",
379
+ "labels": [lb.get("name", "") for lb in d.get("labels", [])],
380
+ "state": d.get("state", ""),
381
+ }
382
+
383
+
384
+ def fetch_parent(number: int):
385
+ """Parent issue number, or None (FREI = atomar)."""
386
+ rc, out = _run(["python3", "scripts/board-sync.py", "parent-of", str(number)])
387
+ if rc != 0 or not out or out.strip() == "FREI":
388
+ return None
389
+ try:
390
+ return int(out.strip())
391
+ except ValueError:
392
+ return None
393
+
394
+
395
+ def fetch_blocked_by(number: int):
396
+ """Normalized native blocked-by list via board-sync `deps --json`, or
397
+ "unresolved" on any failure (fail-closed at the caller pattern)."""
398
+ rc, out = _run(["python3", "scripts/board-sync.py", "deps", str(number), "--json"])
399
+ if rc != 0 or not out:
400
+ return "unresolved"
401
+ try:
402
+ return json.loads(out)
403
+ except json.JSONDecodeError:
404
+ return "unresolved"
405
+
406
+
407
+ def fetch_children(number: int):
408
+ """(child_numbers, truncated). truncated=True if exactly 100 came back
409
+ (board-sync subIssues(first:100) — can't prove completeness)."""
410
+ rc, out = _run(["python3", "scripts/board-sync.py", "children-of", str(number)])
411
+ if rc != 0 or not out:
412
+ return [], False
413
+ # one issue number per line — anchor per-line so a stray warning/word can't
414
+ # contribute spurious digits.
415
+ nums = [int(m.group(1)) for m in re.finditer(r"^\s*(\d+)\s*$", out, re.MULTILINE)]
416
+ return nums, len(nums) >= 100
417
+
418
+
419
+ def build_and_evaluate(issue_number: int, mode: str, intent: str,
420
+ handoff_content: str = "") -> dict:
421
+ """Fetch the rooted local graph via gh + evaluate. fail-closed: if a target
422
+ is identified but gh cannot resolve it, recommend deny (the MUSS-property)."""
423
+ target = fetch_issue(issue_number)
424
+ if target is None:
425
+ return _fail_closed(issue_number, "could not verify via gh (network/auth)")
426
+
427
+ closed_lookup = {}
428
+ fc = parse_final_cut_depends(target["body"])
429
+ if fc is not None:
430
+ st = fetch_issue(fc)
431
+ if st is None:
432
+ # gh could not resolve the dependency at all → "unresolved", NOT a
433
+ # silent "open" default (that would hide an unverifiable final-cut
434
+ # dependency instead of denying it).
435
+ closed_lookup[fc] = "unresolved"
436
+ else:
437
+ closed_lookup[fc] = "closed" if st.get("state", "").upper() == "CLOSED" else "open"
438
+
439
+ native_blockers = fetch_blocked_by(issue_number)
440
+
441
+ node_kind = classify(target)
442
+ if node_kind in ROOT_KINDS:
443
+ # An anchor or a Programm-PRD is its OWN root — it never lifts to a
444
+ # native parent it might also have (a Welle-Anker under a PRD, or the
445
+ # PRD itself).. `fetch_parent` is intentionally never called here.
446
+ children, truncated = fetch_children(issue_number)
447
+ siblings = [fetch_issue(c) for c in children] if children else []
448
+ if any(s is None for s in siblings):
449
+ return _fail_closed(issue_number, "child fetch failed")
450
+ return evaluate_graph(target, parent=target, siblings=siblings, mode=mode,
451
+ intent=intent, closed_lookup=closed_lookup,
452
+ truncated=truncated, root_kind=node_kind,
453
+ native_blockers=native_blockers)
454
+
455
+ # leaf / wave_stub — lift to the native parent if one exists (2-level
456
+ # rooted-local model), else atomar leaf.
457
+ parent_n = fetch_parent(issue_number)
458
+ if parent_n is None:
459
+ return evaluate_graph(target, mode=mode, intent=intent, closed_lookup=closed_lookup,
460
+ native_blockers=native_blockers)
461
+
462
+ parent = fetch_issue(parent_n)
463
+ if parent is None:
464
+ return _fail_closed(issue_number, "parent fetch failed")
465
+ children, truncated = fetch_children(parent_n)
466
+ siblings = [fetch_issue(c) for c in children] if children else [target]
467
+ if any(s is None for s in siblings):
468
+ return _fail_closed(issue_number, "sibling fetch failed")
469
+ return evaluate_graph(target, parent=parent, siblings=siblings, mode=mode,
470
+ intent=intent, closed_lookup=closed_lookup, truncated=truncated,
471
+ native_blockers=native_blockers)
472
+
473
+
474
+ def _fail_closed(number, why):
475
+ log(f"issue={number} {why} → fail-closed deny")
476
+ return {"graph_coherent": False, "target_buildable": False,
477
+ "deny_recommended": True, "grandfathered": None, "shape_warnings": [],
478
+ "open_blockers": [], "drift_warnings": [],
479
+ "violations": [f"#{number}: {why} — fail-closed (could not verify graph)"]}
480
+
481
+
482
+ def main() -> int:
483
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
484
+ ap.add_argument("--issue", type=int, required=True)
485
+ ap.add_argument("--mode", choices=["handoff", "audit"], default="audit")
486
+ ap.add_argument("--intent", choices=["build", "grill"], default="build")
487
+ ap.add_argument("--json", action="store_true")
488
+ args = ap.parse_args()
489
+
490
+ result = build_and_evaluate(args.issue, args.mode, args.intent)
491
+ log(f"issue={args.issue} mode={args.mode} intent={args.intent} "
492
+ f"coherent={result['graph_coherent']} buildable={result['target_buildable']} "
493
+ f"deny={result['deny_recommended']} violations={len(result['violations'])}")
494
+
495
+ if args.json:
496
+ print(json.dumps(result, ensure_ascii=False))
497
+ else:
498
+ flag = "DENY" if result["deny_recommended"] else "OK"
499
+ print(f"[{flag}] #{args.issue} coherent={result['graph_coherent']} "
500
+ f"buildable={result['target_buildable']}")
501
+ if result.get("grandfathered"):
502
+ print(f" ℹ legacy graph grandfathered via #{result['grandfathered']}")
503
+ for v in result["violations"]:
504
+ print(f" - {v}")
505
+ if result.get("open_blockers"):
506
+ blocked = ", ".join(f"#{n}" for n in result["open_blockers"])
507
+ print(f" ⛔ natively blocked by open {blocked} (gates a build handoff)")
508
+ for w in result.get("drift_warnings", []):
509
+ print(f" ~ drift (non-blocking in audit): {w}")
510
+ for w in result.get("shape_warnings", []):
511
+ print(f" ~ shape (non-blocking): {w}")
512
+ return 0
513
+
514
+
515
+ if __name__ == "__main__":
516
+ sys.exit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """issue_deps.py — pure `## Blocked by` body-mirror logic.
3
+
4
+ Native GitHub issue dependencies (REST `dependencies/blocked_by`) are the SSOT
5
+ for blocking edges; the `## Blocked by` body section is a MACHINE-WRITTEN
6
+ mirror kept for readability (mail/export/search) and for trackers without
7
+ native dependencies (kit portability). On conflict the API wins.
8
+
9
+ Pure module by design (same rationale as anchor_table.py): render/splice/parse
10
+ carry no gh calls and no board_config, so board-sync.py (writer) and
11
+ execute-ready-check.py (drift reader) share one grammar without a subprocess
12
+ seam. Writers: board-sync.py `dep-add`/`dep-remove` — never hand-edited.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ _SECTION_HEADING = "## Blocked by"
19
+ _HEADING_RE = re.compile(r"^#{1,6}\s")
20
+ _BULLET_NUM_RE = re.compile(r"^-\s+#(\d+)\b")
21
+
22
+ _MIRROR_NOTE = ("*(Maschinell gepflegter Spiegel — die nativen "
23
+ "Issue-Dependencies (API) sind die Wahrheit; Pflege via "
24
+ "`scripts/board-sync.py dep-add`/`dep-remove`.)*")
25
+
26
+
27
+ def render_blocked_by_section(blockers: list[dict]) -> str:
28
+ """The full mirror section for a non-empty blocker list."""
29
+ lines = [_SECTION_HEADING, ""]
30
+ lines += [f"- #{b['number']} ({b['state']}) — {b['title']}".rstrip()
31
+ for b in blockers]
32
+ lines += ["", _MIRROR_NOTE]
33
+ return "\n".join(lines)
34
+
35
+
36
+ def _locate_section(lines: list[str]) -> tuple[int, int] | None:
37
+ """(start, end) line span of the existing section, end exclusive."""
38
+ start = next((i for i, ln in enumerate(lines)
39
+ if ln.strip() == _SECTION_HEADING), None)
40
+ if start is None:
41
+ return None
42
+ end = next((i for i in range(start + 1, len(lines))
43
+ if _HEADING_RE.match(lines[i])), len(lines))
44
+ return start, end
45
+
46
+
47
+ def splice_blocked_by_section(body: str, blockers: list[dict]) -> str:
48
+ """Replace/append/remove the mirror section in `body`. Idempotent."""
49
+ lines = (body or "").splitlines()
50
+ span = _locate_section(lines)
51
+ section = render_blocked_by_section(blockers).splitlines() if blockers else []
52
+ if span is None:
53
+ if not section:
54
+ return body
55
+ head = "\n".join(lines).rstrip("\n")
56
+ return f"{head}\n\n" + "\n".join(section) + "\n"
57
+ start, end = span
58
+ new = lines[:start] + section + (["" ] if section and end < len(lines) else []) + lines[end:]
59
+ # collapse a doubled blank line left by removal
60
+ out = re.sub(r"\n{3,}", "\n\n", "\n".join(new))
61
+ return out if out.endswith("\n") else out + "\n"
62
+
63
+
64
+ def open_blocker_numbers(blockers: list[dict]) -> list[int]:
65
+ """Numbers of the still-open blockers — the only ones that gate a build.
66
+ Lives here (not in board-sync.py) so writer AND drift reader share the
67
+ one "open" predicate."""
68
+ return [b["number"] for b in blockers if b.get("state") == "open"]
69
+
70
+
71
+ def parse_blocked_by_numbers(body: str) -> list[int]:
72
+ """Issue numbers listed in the mirror section (drift-check reader side)."""
73
+ lines = (body or "").splitlines()
74
+ span = _locate_section(lines)
75
+ if span is None:
76
+ return []
77
+ start, end = span
78
+ return [int(m.group(1)) for ln in lines[start + 1:end]
79
+ if (m := _BULLET_NUM_RE.match(ln.strip()))]