@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,1232 @@
1
+ #!/usr/bin/env python3
2
+ """board-sync.py — single GitHub Projects-v2 board-sync / sub-issue helper.
3
+
4
+ Encapsulates the five board mechanics the planning skills used to inline as bare
5
+ `gh` snippets (board-to-waves, to-prd, to-issues):
6
+
7
+ 1. GraphQL-Link — native parent↔child sub-issue link (`link`)
8
+ 2. one-parent-check — an issue may have only one parent (`parent-of`, folded into `link`)
9
+ 3. preview-header — `GraphQL-Features: sub_issues` on every sub-issue call
10
+ 4. Wave-Stempel — stamp the Wave (number) field (`--wave` on `create`/`add`)
11
+ 5. board-sync — add an issue to the board + set Status/Wave/Cluster/Path fields
12
+
13
+ Board-specific values (field IDs, status names, labels) are NOT inlined here —
14
+ board_config reads them from the `board-sync:profile` block in
15
+ docs/agents/board-sync.md, so the published script carries no project-private
16
+ constants. Re-verify the IDs against a changed board with
17
+ `gh project field-list <n> --owner <owner> --format json`.
18
+
19
+ All write commands accept --dry-run (prints the gh argv it would run, no network).
20
+ The single `_gh` subprocess seam is monkeypatched in scripts/test_board_sync.py so
21
+ tests never touch the real API.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import datetime
27
+ import json
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+ from typing import Optional
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ from board_config import ( # noqa: E402
37
+ ConfigError, load_board_config, phase_field_id, program_type_label,
38
+ status_roles, STATUS_ROLE_KEYS,
39
+ )
40
+ # stamp-batch / field-value / promote-guards — pure logic, see module docstring.
41
+ from board_fields import ( # noqa: E402
42
+ build_stamps, chunk_stamps, build_stamp_mutation, parse_batch_response, repair_command,
43
+ extract_field_value, wave_mismatch_guard, program_prd_refusal,
44
+ )
45
+ # Node-kind classifier — `promote`'s Program-PRD refusal + `program-sync`'s
46
+ # node-kind dispatch guard both need to tell a Program-PRD apart from a Welle-Anker.
47
+ from node_kind import classify_node, PROGRAM # noqa: E402
48
+ # Blocked-by body mirror — pure render/splice/parse + the shared
49
+ # "open blocker" predicate, shared with the execute-ready drift check;
50
+ # native dependencies (API) are the truth.
51
+ from issue_deps import open_blocker_numbers, splice_blocked_by_section # noqa: E402
52
+
53
+ # --- Board profile (SSOT: docs/agents/board-sync.md `board-sync:profile`) ----
54
+ # No inline board IDs: board_config reads them from the profile so the published
55
+ # kit carries no project-private constants (a consumer's profile is seeded by
56
+ # /setup-workflow). A bad/missing profile fails clean here (one-line stderr, exit
57
+ # 1) instead of a raw ConfigError traceback.
58
+ try:
59
+ _CFG = load_board_config()
60
+ except ConfigError as exc:
61
+ print(f"error: {exc}", file=sys.stderr)
62
+ sys.exit(1)
63
+ REPO = _CFG["repo"]
64
+ REPO_NAME = REPO.split("/", 1)[1]
65
+ PROJECT_NUMBER = _CFG["project"]["number"]
66
+ PROJECT_OWNER = _CFG["project"]["owner"]
67
+ PROJECT_NODE_ID = _CFG["project"]["nodeId"]
68
+ STATUS_FIELD_ID = _CFG["fields"]["status"]["id"]
69
+ STATUS_OPTIONS = _CFG["fields"]["status"]["options"]
70
+ WAVE_FIELD_ID = _CFG["fields"]["wave"]
71
+ CLUSTER_FIELD_ID = _CFG["fields"]["cluster"]
72
+ SPEC_PATH_FIELD_ID = _CFG["fields"]["specPath"]
73
+ PLAN_PATH_FIELD_ID = _CFG["fields"]["planPath"]
74
+ READY_FOR_AGENT = _CFG["labels"]["readyForAgent"]
75
+ TYPE_PREFIX = _CFG["labels"]["typePrefix"]
76
+ CLUSTER_TYPE_LABEL = _CFG["labels"]["clusterType"]
77
+ WAVE_STUB_LABEL = _CFG["labels"]["waveStub"]
78
+ # Optional Programm-Flughöhe key — literal-default getter, so
79
+ # an existing profile without `labels.programType` keeps working unchanged.
80
+ PROGRAM_TYPE_LABEL = program_type_label(_CFG)
81
+ # Optional semantic role map — empty dict when the profile has no
82
+ # `fields.status.roles` yet; role-consuming paths degrade visibly (see
83
+ # `_status_roles_or_hint` / `resolve_status_role`), never crash.
84
+ STATUS_ROLES = status_roles(_CFG)
85
+
86
+
87
+ def _status_roles_or_hint(context: str) -> dict:
88
+ """STATUS_ROLES, with a one-line stderr hint when the profile has no role
89
+ map — status-name-based signals then read as 'open' (⬜) while PR-based
90
+ signals keep working; the sync itself proceeds."""
91
+ if not STATUS_ROLES:
92
+ print(f"hint: {context}: no fields.status.roles in the board profile — "
93
+ "board-status names can't be interpreted (PR signals still work); "
94
+ "add the roles map (see docs/agents/board-sync.md)", file=sys.stderr)
95
+ return STATUS_ROLES
96
+ PROJECT_ITEM_LIST_LIMIT = 2000
97
+ GH_TIMEOUT_SECONDS = 15 # a hanging gh prompt must not block a session indefinitely
98
+
99
+ # Sub-issues GraphQL API is behind a preview feature gate per account.
100
+ SUB_ISSUES_HEADER = "GraphQL-Features: sub_issues"
101
+
102
+ ADD_SUBISSUE_MUTATION = (
103
+ "mutation($parent:ID!,$child:ID!){"
104
+ "addSubIssue(input:{issueId:$parent,subIssueId:$child}){"
105
+ "issue{number} subIssue{number}}}"
106
+ )
107
+ REMOVE_SUBISSUE_MUTATION = (
108
+ "mutation($parent:ID!,$child:ID!){"
109
+ "removeSubIssue(input:{issueId:$parent,subIssueId:$child}){"
110
+ "issue{number} subIssue{number}}}"
111
+ )
112
+ PARENT_QUERY = (
113
+ "query($owner:String!,$repo:String!,$num:Int!){"
114
+ "repository(owner:$owner,name:$repo){issue(number:$num){parent{number title}}}}"
115
+ )
116
+ CHILDREN_QUERY = (
117
+ "query($owner:String!,$repo:String!,$num:Int!){"
118
+ "repository(owner:$owner,name:$repo){issue(number:$num){"
119
+ "subIssues(first:100){nodes{number}}}}}"
120
+ )
121
+
122
+
123
+ class GhError(RuntimeError):
124
+ """Raised when the gh CLI exits non-zero."""
125
+
126
+
127
+ # --- subprocess seam (the only thing tests monkeypatch) ----------------------
128
+ def _gh(args: list[str]) -> str:
129
+ """Run `gh <args>`, return stdout. Raise GhError on non-zero exit or timeout."""
130
+ try:
131
+ result = subprocess.run(["gh", *args], capture_output=True, text=True,
132
+ timeout=GH_TIMEOUT_SECONDS)
133
+ except subprocess.TimeoutExpired as exc:
134
+ raise GhError(f"gh {' '.join(args)} timed out after {GH_TIMEOUT_SECONDS}s") from exc
135
+ if result.returncode != 0:
136
+ # `gh api graphql` exits non-zero when the response body carries GraphQL
137
+ # `errors` even alongside a partial `data` (stamp-batch's per-alias
138
+ # failure case) — attach the raw stdout to the exception so a
139
+ # caller that WANTS that partial body (stamp-batch) can still parse it,
140
+ # without changing `_gh`'s raise-on-nonzero contract for every other
141
+ # caller that doesn't look at `.stdout`.
142
+ err = GhError(f"gh {' '.join(args)} failed: {result.stderr.strip()}")
143
+ err.stdout = result.stdout
144
+ raise err
145
+ return result.stdout
146
+
147
+
148
+ def _gh_json(args: list[str]):
149
+ return json.loads(_gh(args))
150
+
151
+
152
+ def _print_dry(args: list[str]) -> None:
153
+ print("[dry-run] gh " + " ".join(args))
154
+
155
+
156
+ # --- pure logic (directly unit-tested) ---------------------------------------
157
+ _PROGRAM_CREATE_IDENTITY_RE = re.compile(
158
+ r"<!--\s*(program-(?:stub|leaf)-source):\s*([^>]+?)\s*-->"
159
+ )
160
+
161
+
162
+ def extract_program_create_identity(body: str) -> Optional[str]:
163
+ """Stable to-waves identity carried by a stub/leaf body, if present."""
164
+ match = _PROGRAM_CREATE_IDENTITY_RE.search(body)
165
+ return f"{match.group(1)}: {match.group(2).strip()}" if match else None
166
+
167
+
168
+ def _find_open_issue_by_program_identity(body: str) -> Optional[dict]:
169
+ """Search-before-create for to-waves crash/retry safety.
170
+
171
+ GitHub search does not index HTML comments, so fetch the bounded open set and
172
+ compare the parsed marker locally. More than one match is drift: never pick a
173
+ winner silently.
174
+ """
175
+ identity = extract_program_create_identity(body)
176
+ if identity is None:
177
+ return None
178
+ issues = _gh_json([
179
+ "issue", "list", "--repo", REPO, "--state", "open", "--limit", "500",
180
+ "--json", "number,url,body",
181
+ ])
182
+ matches = [
183
+ issue for issue in issues
184
+ if extract_program_create_identity(issue.get("body") or "") == identity
185
+ ]
186
+ if len(matches) > 1:
187
+ numbers = ", ".join(f"#{issue['number']}" for issue in matches)
188
+ raise ValueError(f"duplicate {identity} issues: {numbers}")
189
+ return matches[0] if matches else None
190
+
191
+
192
+ def compute_next_wave(items: list[dict]) -> int:
193
+ """Next monotone wave number = max(assigned wave) + 1, or 1 if none."""
194
+ waves = [it.get("wave") for it in items if it.get("wave") is not None]
195
+ return max(waves) + 1 if waves else 1
196
+
197
+
198
+ def extract_parent_number(data: dict) -> Optional[int]:
199
+ """Pull the parent issue number out of the parent-query GraphQL response."""
200
+ issue = (data.get("data") or {}).get("repository", {}).get("issue")
201
+ parent = issue.get("parent") if issue else None
202
+ return parent["number"] if parent else None
203
+
204
+
205
+ def extract_children_numbers(data: dict) -> list[int]:
206
+ """Pull the native sub-issue numbers out of the children-query response."""
207
+ issue = (data.get("data") or {}).get("repository", {}).get("issue")
208
+ nodes = ((issue or {}).get("subIssues") or {}).get("nodes") or []
209
+ return [n["number"] for n in nodes]
210
+
211
+
212
+ def extract_item_id(item_add_json: dict) -> str:
213
+ """The project item node id returned by `gh project item-add --format json`."""
214
+ return item_add_json["id"]
215
+
216
+
217
+ def one_parent_decision(existing_parent: Optional[int], target_parent: int) -> str:
218
+ """Decide what `link` should do given the child's current parent.
219
+
220
+ Returns 'link' (free), 'already' (same parent — idempotent no-op), or
221
+ 'conflict' (foreign parent — skip, never silently re-parent).
222
+ """
223
+ if existing_parent is None:
224
+ return "link"
225
+ if existing_parent == target_parent:
226
+ return "already"
227
+ return "conflict"
228
+
229
+
230
+ def hitl_guard(hitl: bool, labels: list[str]) -> None:
231
+ """A HITL child is not buildable yet — it must never carry ready-for-agent.
232
+
233
+ AFK and HITL children are both Status Spec, so the status cannot discriminate;
234
+ only the explicit --hitl signal can. Raises ValueError on the contradiction.
235
+ """
236
+ if hitl and READY_FOR_AGENT in (labels or []):
237
+ raise ValueError(
238
+ f"HITL issue must not carry '{READY_FOR_AGENT}' — a HITL slice is not "
239
+ "buildable until grilled. Drop the label or drop --hitl.")
240
+
241
+
242
+ def resolve_status_option(name: str) -> str:
243
+ if name not in STATUS_OPTIONS:
244
+ raise ValueError(f"unknown status {name!r}; valid: {', '.join(STATUS_OPTIONS)}")
245
+ return STATUS_OPTIONS[name]
246
+
247
+
248
+ _ROLES_MIGRATION_SNIPPET = (
249
+ 'no fields.status.roles in the board profile — add it to the profile block '
250
+ 'in docs/agents/board-sync.md, mapping each role to YOUR board\'s status '
251
+ 'option names (any language), e.g.:\n'
252
+ ' "roles": {\n'
253
+ ' "idea": "Idea", "triaged": "Triaged", "spec": "Spec",\n'
254
+ ' "inProgress": "In Progress", "review": "Review", "done": "Done"\n'
255
+ ' }')
256
+
257
+
258
+ def resolve_status_role(role: str, roles: dict) -> str:
259
+ """Option NAME for a semantic role key, via the profile's roles map.
260
+ Fails loud + instructive (missing map → migration snippet; unset role →
261
+ the configured roles) — an explicit --status-role call never guesses."""
262
+ if not roles:
263
+ raise ValueError(_ROLES_MIGRATION_SNIPPET)
264
+ name = roles.get(role)
265
+ if not name:
266
+ raise ValueError(
267
+ f"status role {role!r} not set in fields.status.roles; "
268
+ f"configured roles: {', '.join(sorted(roles))}")
269
+ return name
270
+
271
+
272
+ def stamp_arg_list(item_id, wave, status, cluster, spec_path, plan_path) -> list[list[str]]:
273
+ """Build one `project item-edit` argv per field that was provided."""
274
+ base = ["project", "item-edit", "--id", item_id, "--project-id", PROJECT_NODE_ID]
275
+ out: list[list[str]] = []
276
+ if wave is not None:
277
+ out.append([*base, "--field-id", WAVE_FIELD_ID, "--number", str(wave)])
278
+ if status is not None:
279
+ out.append([*base, "--field-id", STATUS_FIELD_ID,
280
+ "--single-select-option-id", resolve_status_option(status)])
281
+ if cluster is not None:
282
+ out.append([*base, "--field-id", CLUSTER_FIELD_ID, "--text", cluster])
283
+ if spec_path is not None:
284
+ out.append([*base, "--field-id", SPEC_PATH_FIELD_ID, "--text", spec_path])
285
+ if plan_path is not None:
286
+ out.append([*base, "--field-id", PLAN_PATH_FIELD_ID, "--text", plan_path])
287
+ return out
288
+
289
+
290
+ def issue_number_from_url(url: str) -> int:
291
+ return int(url.rstrip("/").rsplit("/", 1)[-1])
292
+
293
+
294
+ # --- native issue dependencies ----------------------------------------
295
+ # REST blocked_by endpoints (verified live 2026-07-09): the list response carries
296
+ # FULL issue objects and INCLUDES closed blockers — "blocked" always means
297
+ # "has OPEN blockers" downstream, so extraction keeps the state and the open
298
+ # filter is explicit. Writes take the numeric DATABASE id (`issue.id`), never
299
+ # `#number`/`node_id` — resolution stays encapsulated here (`_db_id`).
300
+ def extract_blockers(data: list) -> list[dict]:
301
+ """Normalize the REST blocked_by list response to the four fields we use."""
302
+ return [{"number": it["number"], "id": it["id"],
303
+ "state": it["state"], "title": it.get("title", "")}
304
+ for it in (data or [])]
305
+
306
+
307
+ # --- Phase-1 LoC-offender marker -------------------------------------
308
+ # At the buildability transition (create with ready-for-agent / add --bucket afk),
309
+ # if the slice's `## Blast-Radius` block names a still-listed offender, plant a
310
+ # machine-readable marker so the build session is forewarned (Phase 1b) — the
311
+ # authority remains the PR-push diff gate (Phase 2). Marker-before-ready ordering
312
+ # is enforced by the call sites; no substantiality decision happens here.
313
+ _BLAST_HEADING_RE = re.compile(r"^#{2,3}\s+Blast-Radius\b", re.IGNORECASE)
314
+ _NEXT_HEADING_RE = re.compile(r"^#{1,6}\s")
315
+ _BLAST_FIELD_RE = re.compile(r"^\*\*(?:Primary|Transitive):\*\*\s*(.+)$", re.IGNORECASE)
316
+ _OFFENDER_MARKER_RE = re.compile(r"^<!--\s*loc-offender:.*-->\s*$", re.MULTILINE)
317
+
318
+
319
+ def parse_blast_radius(body: str) -> set:
320
+ """Paths listed under the `## Blast-Radius` block (Primary + Transitive)."""
321
+ lines = (body or "").splitlines()
322
+ start = next((i for i, ln in enumerate(lines) if _BLAST_HEADING_RE.match(ln)), None)
323
+ if start is None:
324
+ return set()
325
+ paths: set = set()
326
+ for ln in lines[start + 1:]:
327
+ if _NEXT_HEADING_RE.match(ln):
328
+ break
329
+ m = _BLAST_FIELD_RE.match(ln.strip())
330
+ if not m:
331
+ continue
332
+ for tok in m.group(1).split(","):
333
+ tok = tok.strip().strip("`").strip()
334
+ if tok and "/" in tok:
335
+ paths.add(tok)
336
+ return paths
337
+
338
+
339
+ def loc_offender_hits(body: str, offenders: set) -> list:
340
+ """Sorted intersection of the Blast-Radius paths with the offender baseline."""
341
+ return sorted(parse_blast_radius(body) & set(offenders))
342
+
343
+
344
+ def plant_offender_marker(body: str, hits: list) -> str:
345
+ """Insert/replace the `<!-- loc-offender: … -->` marker at the top. Idempotent."""
346
+ if not hits:
347
+ return body
348
+ body = _OFFENDER_MARKER_RE.sub("", body or "").lstrip("\n")
349
+ marker = f"<!-- loc-offender: {','.join(hits)} -->"
350
+ return f"{marker}\n{body}"
351
+
352
+
353
+ def read_offenders() -> set:
354
+ """The offender baseline from the single SSOT (max-lines-allowlist.json)."""
355
+ p = Path(__file__).resolve().parent.parent / "max-lines-allowlist.json"
356
+ return set(json.loads(p.read_text(encoding="utf-8")).get("offenders", []))
357
+
358
+
359
+ def bucket_label_args(issue: int, bucket: Optional[str]) -> Optional[list[str]]:
360
+ """`gh issue edit` argv for the workflow-label write of a bucket, or None.
361
+
362
+ The sanctioned route for an existing leaf's workflow label (§5a forbids a bare
363
+ `gh issue edit --add-label ready-for-agent`; the helper stays the owner).
364
+
365
+ afk → ADD ready-for-agent (buildable now).
366
+ hitl → STRIP ready-for-agent (not buildable until grilled).
367
+
368
+ Same invariant `create --hitl` enforces by *rejecting* a ready-for-agent label;
369
+ here enforced by *stripping* — a HITL issue can never end up carrying it.
370
+ """
371
+ if bucket is None:
372
+ return None
373
+ base = ["issue", "edit", str(issue), "--repo", REPO]
374
+ # Bucketing = to-issues' atomar publish (5b): the source stub becomes a build-ready
375
+ # leaf, so it leaves the wave-stub planning list too. Strip is unconditional
376
+ # (gh exits 0 on an absent label, verified 2026-06-15).
377
+ if bucket == "afk":
378
+ return [*base, "--add-label", READY_FOR_AGENT, "--remove-label", WAVE_STUB_LABEL]
379
+ if bucket == "hitl":
380
+ return [*base, "--remove-label", READY_FOR_AGENT, "--remove-label", WAVE_STUB_LABEL]
381
+ raise ValueError(f"unknown bucket {bucket!r}; valid: afk, hitl")
382
+
383
+
384
+ def type_labels_to_strip(labels: list[str]) -> list[str]:
385
+ """Non-cluster, non-program `type:*` labels to remove so exactly one
386
+ `type:*` remains.
387
+
388
+ Board convention is one `type:*` per issue; promotion REPLACES the prior
389
+ type (e.g. type:followup) with type:cluster rather than adding a second
390
+ . `PROGRAM_TYPE_LABEL` is excluded too — a Program-PRD's
391
+ type label must never be caught by this strip, regardless of caller.
392
+ """
393
+ return [l for l in labels
394
+ if l.startswith(TYPE_PREFIX) and l != CLUSTER_TYPE_LABEL and l != PROGRAM_TYPE_LABEL]
395
+
396
+
397
+ def promote_label_args(issue: int, strip: Optional[list[str]] = None) -> list[str]:
398
+ """The `gh issue edit` argv that adds type:cluster and strips prior type:* labels.
399
+
400
+ Also strips `wave-stub` and `ready-for-agent`: once promoted the issue is a
401
+ planned Anker, no longer a board-to-waves candidate and never a buildable leaf —
402
+ an Anker carries no buildability bucket (the post-promote audit invariant). A
403
+ clean Draft-PRD has neither label, but an atomar→promote flip source can carry
404
+ `ready-for-agent` (it was first wrongly published as an AFK leaf).
405
+ Unconditional `--remove-label` is safe — gh exits 0 when the label is absent
406
+ (verified 2026-06-15).
407
+ """
408
+ args = ["issue", "edit", str(issue), "--repo", REPO,
409
+ "--add-label", CLUSTER_TYPE_LABEL,
410
+ "--remove-label", WAVE_STUB_LABEL,
411
+ "--remove-label", READY_FOR_AGENT]
412
+ for label in strip or []:
413
+ args += ["--remove-label", label]
414
+ return args
415
+
416
+
417
+ # Existing `Welle <N> — ` prefix (any number, em-dash/en-dash/hyphen separator) —
418
+ # replaced on re-promote so the wave number never doubles up.
419
+ _WAVE_PREFIX_RE = re.compile(r"^\s*Welle\s+\d+\s*[—–-]\s*", re.IGNORECASE)
420
+ # Leading conventional-commit token (`fix:`, `feat(ui):`, `chore!:`) — anchors
421
+ # don't carry these, so strip it when present. Matches only a lowercase token,
422
+ # so prose titles like "Supabase-Residuen entfernen: …" are left intact.
423
+ _CONVENTIONAL_PREFIX_RE = re.compile(r"^[a-z]+(\([^)]*\))?!?:\s*")
424
+
425
+
426
+ def wave_title(current: str, wave: int) -> str:
427
+ """Title for a wave anchor: `Welle <N> — <Thema>`.
428
+
429
+ Strips a leading conventional-commit prefix FIRST, then any existing
430
+ `Welle X — ` prefix underneath it (idempotent re-promote), then re-prefixes
431
+ with the given wave. Order matters: a title like `fix: Welle 7 — X` only
432
+ reveals its `Welle 7 — ` prefix once the conventional prefix is gone —
433
+ stripping Wave first leaves it intact and doubles up into
434
+ `Welle 29 — Welle 7 — X`.
435
+ """
436
+ thema = _CONVENTIONAL_PREFIX_RE.sub("", current)
437
+ thema = _WAVE_PREFIX_RE.sub("", thema).strip()
438
+ return f"Welle {wave} — {thema}"
439
+
440
+
441
+ def title_edit_args(issue: int, title: str) -> list[str]:
442
+ """The `gh issue edit` argv that sets an issue's title."""
443
+ return ["issue", "edit", str(issue), "--repo", REPO, "--title", title]
444
+
445
+
446
+ # --- gh-backed helpers -------------------------------------------------------
447
+ def _blocked_by_list(num: int) -> list[dict]:
448
+ """Native blocked_by edges of an issue (open AND closed blockers)."""
449
+ data = _gh_json(["api", f"repos/{REPO}/issues/{num}/dependencies/blocked_by?per_page=100"])
450
+ return extract_blockers(data)
451
+
452
+
453
+ def _parent_of(num: int) -> Optional[int]:
454
+ data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
455
+ "-f", f"query={PARENT_QUERY}",
456
+ "-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
457
+ "-F", f"num={num}"])
458
+ return extract_parent_number(data)
459
+
460
+
461
+ def _children_of(num: int) -> list[int]:
462
+ data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
463
+ "-f", f"query={CHILDREN_QUERY}",
464
+ "-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
465
+ "-F", f"num={num}"])
466
+ return extract_children_numbers(data)
467
+
468
+
469
+ def _node_id(num: int) -> str:
470
+ return _gh(["issue", "view", str(num), "--repo", REPO, "--json", "id", "-q", ".id"]).strip()
471
+
472
+
473
+ def _issue_type_labels(num: int) -> list[str]:
474
+ """Current label names on an issue (one per line)."""
475
+ out = _gh(["issue", "view", str(num), "--repo", REPO,
476
+ "--json", "labels", "--jq", ".labels[].name"])
477
+ return [l.strip() for l in out.splitlines() if l.strip()]
478
+
479
+
480
+ def _issue_title(num: int) -> str:
481
+ """Current title of an issue."""
482
+ return _gh(["issue", "view", str(num), "--repo", REPO,
483
+ "--json", "title", "--jq", ".title"]).strip()
484
+
485
+
486
+ # --- field-value: read a Projects-v2 item's current field value ------
487
+ # ID-driven (matches by `fieldId`, never a field NAME) — the profile only ever
488
+ # carries opaque field ids, so this stays consistent with every other read/write
489
+ # in this file instead of introducing a new required "field name" key.
490
+ FIELD_VALUE_QUERY = (
491
+ "query($owner:String!,$repo:String!,$num:Int!){"
492
+ "repository(owner:$owner,name:$repo){issue(number:$num){"
493
+ "projectItems(first:10){nodes{id project{id} "
494
+ "fieldValues(first:20){nodes{"
495
+ "... on ProjectV2ItemFieldNumberValue{number field{... on ProjectV2FieldCommon{id}}} "
496
+ "... on ProjectV2ItemFieldSingleSelectValue{name optionId field{... on ProjectV2FieldCommon{id}}} "
497
+ "... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2FieldCommon{id}}}"
498
+ "}}}}}}}"
499
+ )
500
+
501
+
502
+ def _field_value(issue: int, field_id: str) -> Optional[dict]:
503
+ data = _gh_json(["api", "graphql", "-f", f"query={FIELD_VALUE_QUERY}",
504
+ "-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
505
+ "-F", f"num={issue}"])
506
+ return extract_field_value(data, PROJECT_NODE_ID, field_id)
507
+
508
+
509
+ def _write_issue_body(issue: int, new_body: str) -> None:
510
+ """Write an issue body via a temp file + `gh issue edit --body-file` — the
511
+ one shared write mechanic for every body-rewriting command in this file."""
512
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
513
+ f.write(new_body)
514
+ tmp = f.name
515
+ _gh(["issue", "edit", str(issue), "--repo", REPO, "--body-file", tmp])
516
+
517
+
518
+ def _add_and_stamp(url, wave, status, cluster, spec_path, plan_path, dry_run) -> None:
519
+ add_args = ["project", "item-add", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
520
+ "--url", url, "--format", "json"]
521
+ if dry_run:
522
+ _print_dry(add_args)
523
+ for edit in stamp_arg_list("<ITEM_ID>", wave, status, cluster, spec_path, plan_path):
524
+ _print_dry(edit)
525
+ return
526
+ item_id = extract_item_id(_gh_json(add_args))
527
+ for edit in stamp_arg_list(item_id, wave, status, cluster, spec_path, plan_path):
528
+ _gh(edit)
529
+
530
+
531
+ # --- commands ----------------------------------------------------------------
532
+ def cmd_next_wave(_args) -> int:
533
+ items = _gh_json(["project", "item-list", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
534
+ "--limit", str(PROJECT_ITEM_LIST_LIMIT), "--format", "json"]).get("items", [])
535
+ print(compute_next_wave(items))
536
+ return 0
537
+
538
+
539
+ def cmd_parent_of(args) -> int:
540
+ parent = _parent_of(args.issue)
541
+ print(parent if parent is not None else "FREI")
542
+ return 0
543
+
544
+
545
+ def cmd_children_of(args) -> int:
546
+ for num in _children_of(args.issue):
547
+ print(num)
548
+ return 0
549
+
550
+
551
+ def cmd_link(args) -> int:
552
+ decision = one_parent_decision(_parent_of(args.child), args.parent)
553
+ if decision == "already":
554
+ print(f"#{args.child} already a sub-issue of #{args.parent} — skip (idempotent)")
555
+ return 0
556
+ if decision == "conflict":
557
+ existing = _parent_of(args.child)
558
+ print(f"#{args.child} already has parent #{existing} — skip, not re-parenting "
559
+ f"(needs removeSubIssue on the old parent first)")
560
+ # Foreign-parent drift: non-zero for reconcile/publish; zero only in audit mode.
561
+ return 0 if getattr(args, "allow_drift_report", False) else 1
562
+ link_args = ["api", "graphql", "--header", SUB_ISSUES_HEADER,
563
+ "-f", f"query={ADD_SUBISSUE_MUTATION}"]
564
+ if args.dry_run:
565
+ _print_dry([*link_args, "-f", f"parent=<#{args.parent} node-id>",
566
+ "-f", f"child=<#{args.child} node-id>"])
567
+ return 0
568
+ _gh([*link_args, "-f", f"parent={_node_id(args.parent)}", "-f", f"child={_node_id(args.child)}"])
569
+ print(f"linked #{args.child} → #{args.parent}")
570
+ return 0
571
+
572
+
573
+ def cmd_unlink(args) -> int:
574
+ """Remove a native sub-issue link. Parent-checked + idempotent (mirror of cmd_link):
575
+ only unlinks when the child's current parent IS args.parent — never touches a foreign
576
+ parent. The reconcile counterpart for to-issues when a promoted stub still carries
577
+ board-to-waves member sub-issues that are superseded by fresh slices."""
578
+ current = _parent_of(args.child)
579
+ if current is None:
580
+ print(f"#{args.child} has no parent — skip (idempotent)")
581
+ return 0
582
+ if current != args.parent:
583
+ print(f"#{args.child} parent is #{current}, not #{args.parent} — skip, "
584
+ f"not unlinking a foreign parent")
585
+ # Foreign-parent mismatch: non-zero for reconcile/publish; zero only in audit mode.
586
+ return 0 if getattr(args, "allow_drift_report", False) else 1
587
+ unlink_args = ["api", "graphql", "--header", SUB_ISSUES_HEADER,
588
+ "-f", f"query={REMOVE_SUBISSUE_MUTATION}"]
589
+ if args.dry_run:
590
+ _print_dry([*unlink_args, "-f", f"parent=<#{args.parent} node-id>",
591
+ "-f", f"child=<#{args.child} node-id>"])
592
+ return 0
593
+ _gh([*unlink_args, "-f", f"parent={_node_id(args.parent)}", "-f", f"child={_node_id(args.child)}"])
594
+ print(f"unlinked #{args.child} from #{args.parent}")
595
+ return 0
596
+
597
+
598
+ # --- native issue-dependency commands --------------------------------
599
+ def _sync_blocked_by_mirror(issue: int, blockers: list[dict]) -> None:
600
+ """Rewrite the issue's `## Blocked by` body mirror from the given edge list.
601
+ No-op when already in sync (anchor-sync's no-drift contract)."""
602
+ body = _gh(["issue", "view", str(issue), "--repo", REPO, "--json", "body", "-q", ".body"])
603
+ new_body = splice_blocked_by_section(body, blockers)
604
+ if new_body == body:
605
+ print(f"#{issue}: mirror in sync")
606
+ return
607
+ _write_issue_body(issue, new_body)
608
+ print(f"#{issue}: mirror updated")
609
+
610
+
611
+ def cmd_dep_add(args) -> int:
612
+ """Set a native blocked-by edge + refresh the body mirror.
613
+
614
+ Idempotent AND self-healing: an existing edge skips the POST but still
615
+ reconciles the mirror — a failed mirror write is repaired by re-running
616
+ the same command. Dry-run makes NO gh calls (promote contract).
617
+ """
618
+ path = f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by"
619
+ if args.dry_run:
620
+ _print_dry(["api", "--method", "POST", path,
621
+ "-F", f"issue_id=<#{args.blocked_by} database-id>"])
622
+ print(f"[dry-run] would refresh the ## Blocked by mirror of #{args.issue}")
623
+ return 0
624
+ existing = _blocked_by_list(args.issue)
625
+ if args.blocked_by in [b["number"] for b in existing]:
626
+ print(f"#{args.issue} already blocked by #{args.blocked_by} — skip (idempotent)")
627
+ _sync_blocked_by_mirror(args.issue, existing)
628
+ return 0
629
+ # The write takes the numeric DATABASE id — resolved here, never by the caller.
630
+ blocker = _gh_json(["api", f"repos/{REPO}/issues/{args.blocked_by}"])
631
+ _gh(["api", "--method", "POST", path, "-F", f"issue_id={blocker['id']}"])
632
+ print(f"#{args.issue} now blocked by #{args.blocked_by}")
633
+ _sync_blocked_by_mirror(args.issue, existing + extract_blockers([blocker]))
634
+ return 0
635
+
636
+
637
+ def cmd_dep_remove(args) -> int:
638
+ """Remove a native blocked-by edge + refresh the body mirror.
639
+
640
+ Idempotent + self-healing like dep-add; the blocker's DATABASE id comes
641
+ straight from the edge list (path param on DELETE), no extra fetch.
642
+ """
643
+ if args.dry_run:
644
+ _print_dry(["api", "--method", "DELETE",
645
+ f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by/"
646
+ f"<#{args.blocked_by} database-id>"])
647
+ print(f"[dry-run] would refresh the ## Blocked by mirror of #{args.issue}")
648
+ return 0
649
+ existing = _blocked_by_list(args.issue)
650
+ target = next((b for b in existing if b["number"] == args.blocked_by), None)
651
+ if target is None:
652
+ print(f"#{args.issue} not blocked by #{args.blocked_by} — skip (idempotent)")
653
+ _sync_blocked_by_mirror(args.issue, existing)
654
+ return 0
655
+ _gh(["api", "--method", "DELETE",
656
+ f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by/{target['id']}"])
657
+ print(f"#{args.issue} no longer blocked by #{args.blocked_by}")
658
+ _sync_blocked_by_mirror(args.issue,
659
+ [b for b in existing if b["number"] != args.blocked_by])
660
+ return 0
661
+
662
+
663
+ def cmd_deps(args) -> int:
664
+ """Print an issue's native blocked_by edges + the open-blocker count.
665
+ --json emits the normalized list (execute-ready-check's machine seam)."""
666
+ blockers = _blocked_by_list(args.issue)
667
+ if getattr(args, "as_json", False):
668
+ print(json.dumps(blockers, ensure_ascii=False))
669
+ return 0
670
+ if not blockers:
671
+ print(f"#{args.issue}: no blockers")
672
+ return 0
673
+ for b in blockers:
674
+ print(f"- #{b['number']} ({b['state']}) — {b['title']}".rstrip())
675
+ print(f"open blockers: {len(open_blocker_numbers(blockers))}")
676
+ return 0
677
+
678
+
679
+ def cmd_frontier(args) -> int:
680
+ """Buildable frontier of a wave anchor from native edges (orchestrate-wave's
681
+ reader). Per open sub-issue: FREI (0 open blockers) or BLOCKED by #…;
682
+ closed sub-issues print as done. ONE GraphQL round-trip — reuses the
683
+ anchor-sync sub-issues query (state + blockedBy per child), no per-child
684
+ REST fan-out."""
685
+ board = _anchor_board_data(args.issue)
686
+ if not board:
687
+ print(f"#{args.issue}: no native sub-issues — not a wave anchor?", file=sys.stderr)
688
+ return 1
689
+ for child, entry in board.items():
690
+ if entry.get("state") != "open":
691
+ print(f"#{child} done")
692
+ continue
693
+ blockers = open_blocker_numbers(entry.get("blocked_by") or [])
694
+ if blockers:
695
+ print(f"#{child} BLOCKED by {', '.join('#' + str(n) for n in blockers)}")
696
+ else:
697
+ print(f"#{child} FREI")
698
+ return 0
699
+
700
+
701
+ def cmd_create(args) -> int:
702
+ hitl_guard(getattr(args, "hitl", False), args.label or [])
703
+ labels = list(args.label or [])
704
+ body = None
705
+ # Phase-1: a ready-for-agent slice whose Blast-Radius names an offender
706
+ # gets the marker planted INTO the body-file BEFORE `gh issue create`, so the
707
+ # issue is never briefly ready-without-marker (no partial-ready window). Skip in
708
+ # dry-run — that path must read no files / make no writes (parity tests).
709
+ if not args.dry_run:
710
+ body = Path(args.body_file).read_text(encoding="utf-8")
711
+ if READY_FOR_AGENT in labels and body is not None:
712
+ hits = loc_offender_hits(body, read_offenders())
713
+ if hits:
714
+ body = plant_offender_marker(body, hits)
715
+ Path(args.body_file).write_text(body, encoding="utf-8")
716
+ create_args = ["issue", "create", "--repo", REPO, "--title", args.title,
717
+ "--body-file", args.body_file]
718
+ if getattr(args, "wave_stub", False):
719
+ labels.append(WAVE_STUB_LABEL)
720
+ for label in labels:
721
+ create_args += ["--label", label]
722
+ if args.dry_run:
723
+ _print_dry(create_args)
724
+ _add_and_stamp("<new-url>", args.wave, args.status, args.cluster, None, None, dry_run=True)
725
+ return 0
726
+ existing = _find_open_issue_by_program_identity(body or "")
727
+ if existing:
728
+ issue_no = existing["number"]
729
+ url = existing["url"]
730
+ print(f"#{issue_no} {url} (existing)")
731
+ else:
732
+ url = _gh(create_args).strip().splitlines()[-1]
733
+ issue_no = issue_number_from_url(url)
734
+ # Print the number/URL immediately — a board-sync failure below must never
735
+ # swallow it. A retry without this would blind-recreate a duplicate issue
736
+ # (the create already succeeded, only the board-sync half failed).
737
+ print(f"#{issue_no} {url}")
738
+ try:
739
+ _add_and_stamp(url, args.wave, args.status, args.cluster, None, None, dry_run=False)
740
+ except GhError as exc:
741
+ repair = f"python3 scripts/board-sync.py add --issue {issue_no}"
742
+ if args.wave is not None:
743
+ repair += f" --wave {args.wave}"
744
+ if args.status:
745
+ repair += f" --status {args.status}"
746
+ if args.cluster:
747
+ repair += f" --cluster {args.cluster}"
748
+ print(f"board-sync of #{issue_no} FAILED after create: {exc}")
749
+ print(f" repair (idempotent): {repair}")
750
+ return 1
751
+ return 0
752
+
753
+
754
+ def cmd_promote(args) -> int:
755
+ """Promote a Draft-PRD to Anker: type:cluster label + Wave (+ Status), ordered.
756
+
757
+ Requires an explicit --wave (caller reads `next-wave` first) so there is no
758
+ in-promote race window. On a mid-transaction gh failure it prints what was
759
+ already set + an idempotent repair command and exits non-zero (no silent
760
+ half-promote).
761
+
762
+ Two guards run before any write (real path only — a dry-run makes NO
763
+ gh calls at all, guards included, same contract as every other dry-run
764
+ branch in this file): a Program-PRD is never a promote target (it is the
765
+ native anchor OVER Wellen, not itself a Welle), and a stub already stamped
766
+ with a DIFFERENT Wave than --wave refuses rather than silently overwriting.
767
+ """
768
+ url = f"https://github.com/{REPO}/issues/{args.issue}"
769
+ rename = not args.no_rename
770
+ if args.dry_run:
771
+ _print_dry(promote_label_args(args.issue))
772
+ if rename:
773
+ print(f"[dry-run] gh issue edit {args.issue} --title 'Welle {args.wave} — <Thema>'")
774
+ _add_and_stamp(url, args.wave, args.status, None, None, None, dry_run=True)
775
+ return 0
776
+ labels = _issue_type_labels(args.issue)
777
+ body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
778
+ is_program = (classify_node({"body": body, "labels": labels},
779
+ cluster_type_label=CLUSTER_TYPE_LABEL,
780
+ wave_stub_label=WAVE_STUB_LABEL) == PROGRAM
781
+ or PROGRAM_TYPE_LABEL in labels)
782
+ refusal = program_prd_refusal(is_program, args.issue)
783
+ if refusal:
784
+ print(f"error: {refusal}", file=sys.stderr)
785
+ return 1
786
+ current = _field_value(args.issue, WAVE_FIELD_ID)
787
+ mismatch = wave_mismatch_guard(current.get("number") if current else None, args.wave)
788
+ if mismatch:
789
+ print(f"error: {mismatch}", file=sys.stderr)
790
+ return 1
791
+ done: list[str] = []
792
+ try:
793
+ strip = type_labels_to_strip(labels)
794
+ _gh(promote_label_args(args.issue, strip))
795
+ done.append("type:cluster label"
796
+ + (f" (stripped {', '.join(strip)})" if strip else ""))
797
+ if rename:
798
+ new_title = wave_title(_issue_title(args.issue), args.wave)
799
+ _gh(title_edit_args(args.issue, new_title))
800
+ done.append(f"title={new_title!r}")
801
+ _add_and_stamp(url, args.wave, args.status, None, None, None, dry_run=False)
802
+ done.append(f"Wave={args.wave}" + (f", Status={args.status}" if args.status else ""))
803
+ except GhError as exc:
804
+ repair = f"python3 scripts/board-sync.py promote --issue {args.issue} --wave {args.wave}"
805
+ if args.status:
806
+ repair += f" --status {args.status}"
807
+ if args.no_rename:
808
+ repair += " --no-rename"
809
+ print(f"promote of #{args.issue} FAILED mid-transaction: {exc}")
810
+ print(f" already set: {', '.join(done) or 'nothing'}")
811
+ print(f" repair (idempotent): {repair}")
812
+ return 1
813
+ print(f"promoted #{args.issue} → Anker (type:cluster, Wave={args.wave})")
814
+ return 0
815
+
816
+
817
+ def _plant_marker_on_existing(issue: int) -> None:
818
+ """Fetch the live issue body; if its Blast-Radius names an offender, rewrite the
819
+ body with the marker via `gh issue edit --body-file`. No-op when no hit."""
820
+ body = _gh(["issue", "view", str(issue), "--repo", REPO, "--json", "body", "-q", ".body"])
821
+ hits = loc_offender_hits(body, read_offenders())
822
+ if not hits:
823
+ return
824
+ _write_issue_body(issue, plant_offender_marker(body, hits))
825
+
826
+
827
+ def cmd_add(args) -> int:
828
+ url = args.url or f"https://github.com/{REPO}/issues/{args.issue}"
829
+ issue_no = args.issue or issue_number_from_url(url)
830
+ _add_and_stamp(url, args.wave, args.status, args.cluster, args.spec_path, args.plan_path,
831
+ dry_run=args.dry_run)
832
+ # Phase-1: for `--bucket afk`, plant the offender marker (fetch body →
833
+ # edit body) BEFORE the ready-for-agent label below, so an agent never grabs a
834
+ # ready issue whose body lacks the marker (Codex-R1-F6 ordering). Dry-run makes
835
+ # no gh calls (the body fetch is a network read), so it only notes the step.
836
+ if getattr(args, "bucket", None) == "afk":
837
+ if args.dry_run:
838
+ print(f"[dry-run] would check #{issue_no} Blast-Radius ∩ offenders + plant marker")
839
+ else:
840
+ _plant_marker_on_existing(issue_no)
841
+ label_args = bucket_label_args(issue_no, getattr(args, "bucket", None))
842
+ if label_args is not None:
843
+ if args.dry_run:
844
+ _print_dry(label_args)
845
+ else:
846
+ _gh(label_args)
847
+ if not args.dry_run:
848
+ print(f"synced {url} to board")
849
+ return 0
850
+
851
+
852
+ # --- anchor-sync: regenerate the wave-anchor Slices-table volatile cells
853
+ # Pure table logic (parse/merge/refresh/render/splice) lives in anchor_table.py so
854
+ # it is independently testable without gh/config; this file keeps only the gh-backed
855
+ # board fetch + the command wiring. See anchor_table.py for the design rationale.
856
+ from anchor_table import ( # noqa: E402
857
+ SLICE_TABLE_START, SLICE_TABLE_END,
858
+ split_pipe_row, is_separator_row, parse_pipe_table, render_pipe_table,
859
+ col_index, first_subissue_num,
860
+ status_token_from_board, status_base, refresh_status_cell,
861
+ branch_from_board, refresh_branch_cell, merge_slice_rows,
862
+ locate_slice_table, current_slice_table, splice_slice_table,
863
+ extract_anchor_board_data,
864
+ )
865
+
866
+ # Status fetched generically and matched by FIELD ID in the extractor
867
+ # — no `fieldValueByName(name:"Status")`, a consumer's field may be named anything.
868
+ ANCHOR_SLICES_QUERY = (
869
+ "query($owner:String!,$repo:String!,$num:Int!){"
870
+ "repository(owner:$owner,name:$repo){issue(number:$num){"
871
+ "subIssues(first:100){nodes{number title state "
872
+ "closedByPullRequestsReferences(first:10,includeClosedPrs:true)"
873
+ "{nodes{number state headRefName}} "
874
+ "blockedBy(first:20){nodes{number state}} "
875
+ "projectItems(first:10){nodes{fieldValues(first:20){nodes{"
876
+ "... on ProjectV2ItemFieldSingleSelectValue{name "
877
+ "field{... on ProjectV2SingleSelectField{id}}}"
878
+ "}}}}}}}}}"
879
+ )
880
+
881
+
882
+ def _anchor_board_data(num: int) -> dict:
883
+ data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
884
+ "-f", f"query={ANCHOR_SLICES_QUERY}",
885
+ "-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
886
+ "-F", f"num={num}"])
887
+ return extract_anchor_board_data(data, STATUS_FIELD_ID)
888
+
889
+
890
+ def cmd_anchor_sync(args) -> int:
891
+ board = _anchor_board_data(args.issue)
892
+ body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
893
+ headers, rows = current_slice_table(body)
894
+ if not headers:
895
+ print(f"#{args.issue}: no slice table found — nothing to sync", file=sys.stderr)
896
+ return 1
897
+ new_rows, appended = merge_slice_rows(headers, rows, board,
898
+ _status_roles_or_hint("anchor-sync"))
899
+ new_body = splice_slice_table(body, render_pipe_table(headers, new_rows))
900
+ appended_note = (f"; +{len(appended)} new sub-issue row(s): "
901
+ f"{', '.join('#' + str(s) for s in appended)}" if appended else "")
902
+ if new_body == body:
903
+ print(f"#{args.issue}: slice table already in sync (no drift)")
904
+ return 0
905
+ if args.dry_run:
906
+ print(f"[dry-run] would update #{args.issue} slice table{appended_note}")
907
+ print(render_pipe_table(headers, new_rows))
908
+ return 0
909
+ _write_issue_body(args.issue, new_body)
910
+ print(f"synced #{args.issue} slice table from board{appended_note}")
911
+ return 0
912
+
913
+
914
+ # --- validate-graph: counted Programm-Graph preflight for a Program-PRD
915
+ # All parsing/validation logic lives in program_graph.py (PURE — no gh/board_config);
916
+ # this handler stays thin: load config, read the PRD body + each native wave-stub's
917
+ # body via the existing `_gh` seam, call the module, print the counted report.
918
+ # Read-only by construction (only `_gh` reads, no mutating call on this path at
919
+ # all) — no mutation surface to guard against, regardless of read count.
920
+ from program_graph import ( # noqa: E402
921
+ validate_program_graph, render_report, parse_wellenplan_table, render_wellenplan_table,
922
+ )
923
+
924
+ # A wave-stub's revision marker, stamped by to-waves (Welle 52 Slice 4, §6):
925
+ # `<!-- program-revision: rN -->`. Read back here so validate-graph can catch a
926
+ # stub whose plan shape drifted from a since-bumped PRD `plan_revision`.
927
+ _STUB_REVISION_RE = re.compile(r"<!--\s*program-revision:\s*r(\d+)\s*-->")
928
+
929
+
930
+ def _fetch_stub_revisions(prd_issue: int) -> list[dict]:
931
+ """The PRD's native wave-stub children, each with its parsed program-revision
932
+ (or None if the marker is missing) — `check_revision_coherence`'s input shape
933
+ (`[{"label": str, "revision": int | None}, …]`). Pre-publish (no children yet)
934
+ this loop never runs, so `[]` degrades revision-coherence to a no-op — the
935
+ unchanged green path."""
936
+ stubs = []
937
+ for child in _children_of(prd_issue):
938
+ body = _gh(["issue", "view", str(child), "--repo", REPO,
939
+ "--json", "body", "-q", ".body"])
940
+ m = _STUB_REVISION_RE.search(body or "")
941
+ stubs.append({"label": f"#{child}", "revision": int(m.group(1)) if m else None})
942
+ return stubs
943
+
944
+
945
+ def cmd_validate_graph(args) -> int:
946
+ body = _gh(["issue", "view", str(args.issue), "--repo", REPO,
947
+ "--json", "body", "-q", ".body"])
948
+ # Defensive read: `fields.phase` is an optional profile key (Welle 52 Slice 3
949
+ # adds it) — absent today, so this must degrade to a visible setup-hint finding
950
+ # inside program_graph, never a KeyError here (CRITICAL RECONCILIATION #2).
951
+ phase_options = _CFG.get("fields", {}).get("phase", {}).get("options")
952
+ stub_revisions = _fetch_stub_revisions(args.issue)
953
+ report = validate_program_graph(body, phase_options=phase_options,
954
+ stub_revisions=stub_revisions)
955
+ print(render_report(report))
956
+ return 1 if report.blocking else 0
957
+
958
+
959
+ # --- stamp-batch: alias-batched field-writes for N items (Wave/Phase) -
960
+ def cmd_stamp_batch(args) -> int:
961
+ if args.items_file:
962
+ items = json.loads(Path(args.items_file).read_text(encoding="utf-8"))
963
+ else:
964
+ if not args.item_id:
965
+ raise ValueError("stamp-batch --issue requires --item-id")
966
+ items = [{"issue": args.issue, "item_id": args.item_id,
967
+ "wave": args.wave, "phase": args.phase}]
968
+ phase_cfg = _CFG.get("fields", {}).get("phase")
969
+ stamps, skipped_phase = build_stamps(items, wave_field_id=WAVE_FIELD_ID, phase_cfg=phase_cfg)
970
+ if skipped_phase:
971
+ print(f"Phase-Feld nicht konfiguriert (`fields.phase` fehlt im Profil) — "
972
+ f"{skipped_phase} Phase-Stempel sichtbar übersprungen (kein stiller Verlust)")
973
+ if not stamps:
974
+ print("stamp-batch: nothing to stamp")
975
+ return 0
976
+ if args.dry_run:
977
+ for chunk in chunk_stamps(stamps):
978
+ query, _ = build_stamp_mutation(chunk, PROJECT_NODE_ID)
979
+ _print_dry(["api", "graphql", "-f", f"query={query}"])
980
+ return 0
981
+ succeeded: list = []
982
+ failed: list = []
983
+ for chunk in chunk_stamps(stamps):
984
+ query, alias_map = build_stamp_mutation(chunk, PROJECT_NODE_ID)
985
+ # Tolerate a non-zero exit that still carries a parseable partial
986
+ # response (`errors` alongside partial `data` — the batched-mutation
987
+ # per-alias failure case). A hard CLI/network failure (no recoverable
988
+ # body) re-raises, same as every other `_gh` caller in this file.
989
+ try:
990
+ out = _gh(["api", "graphql", "-f", f"query={query}"])
991
+ except GhError as exc:
992
+ out = getattr(exc, "stdout", "") or ""
993
+ if not out:
994
+ raise
995
+ s, f = parse_batch_response(json.loads(out), alias_map)
996
+ succeeded += s
997
+ failed += f
998
+ print(f"stamp-batch: {len(succeeded)} von {len(stamps)} Feld-Stempel gesetzt")
999
+ for stamp, msg in failed:
1000
+ print(f" FEHLER #{stamp.issue} {stamp.field_name}: {msg}")
1001
+ print(f" repair: {repair_command(stamp)}")
1002
+ return 1 if failed else 0
1003
+
1004
+
1005
+ # --- field-value: read a project item's current field value ---------
1006
+ def cmd_field_value(args) -> int:
1007
+ if args.field == "wave":
1008
+ field_id = WAVE_FIELD_ID
1009
+ else:
1010
+ field_id = phase_field_id(_CFG)
1011
+ if field_id is None:
1012
+ print(f"error: fields.{args.field} not configured in the board profile", file=sys.stderr)
1013
+ return 1
1014
+ value = _field_value(args.issue, field_id)
1015
+ if value is None:
1016
+ print("unset")
1017
+ return 0
1018
+ print(value.get("number", value.get("name", value.get("text"))))
1019
+ return 0
1020
+
1021
+
1022
+ # --- program-sync: Wellenplan Status-resync for a Program-PRD -------
1023
+ # Own grammar, own command (plan 9b(d): "program-sync statt anchor-sync-Über-
1024
+ # ladung") — the actual parse/render/status logic lives in program_sync.py
1025
+ # (pure) and program_graph.py's public Wellenplan renderer; this
1026
+ # handler stays thin. `_anchor_board_data` is reused as-is: a Program-PRD's
1027
+ # native sub-issues are its promoted Wave-Anchor stubs, fetched exactly like
1028
+ # an Anchor fetches its Slice sub-issues — no second query needed.
1029
+ from program_sync import ( # noqa: E402
1030
+ sync_wellenplan_status, splice_wellenplan_table, checkoff_phase_gates,
1031
+ )
1032
+
1033
+
1034
+ def cmd_program_sync(args) -> int:
1035
+ body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
1036
+ labels = _issue_type_labels(args.issue)
1037
+ is_program = (classify_node({"body": body, "labels": labels},
1038
+ cluster_type_label=CLUSTER_TYPE_LABEL,
1039
+ wave_stub_label=WAVE_STUB_LABEL) == PROGRAM
1040
+ or PROGRAM_TYPE_LABEL in labels)
1041
+ if not is_program:
1042
+ print(f"error: #{args.issue} is not a Program-PRD (no `<!-- prd: program -->` "
1043
+ f"marker or {PROGRAM_TYPE_LABEL!r} label) — use anchor-sync for a "
1044
+ "Welle-Anker.", file=sys.stderr)
1045
+ return 1
1046
+ waves = parse_wellenplan_table(body)
1047
+ if not waves:
1048
+ print(f"#{args.issue}: no Wellenplan table found — nothing to sync", file=sys.stderr)
1049
+ return 1
1050
+ board = _anchor_board_data(args.issue)
1051
+ new_waves = sync_wellenplan_status(waves, board,
1052
+ _status_roles_or_hint("program-sync"))
1053
+ include_phase = any(w.phase is not None for w in waves)
1054
+ new_table = render_wellenplan_table(new_waves, include_phase=include_phase)
1055
+ new_body = splice_wellenplan_table(body, new_table)
1056
+ # upward propagation: a phase whose waves are ALL ✅ checks off its
1057
+ # `## Phasen-Gates` entry (monotone, stamped once — see checkoff_phase_gates).
1058
+ new_body, checked_gates = checkoff_phase_gates(
1059
+ new_body, new_waves, datetime.date.today().isoformat())
1060
+ checked_note = (f"; Phasen-Gate(s) abgehakt: {', '.join(checked_gates)}"
1061
+ if checked_gates else "")
1062
+ if new_body == body:
1063
+ print(f"#{args.issue}: Wellenplan status already in sync (no drift)")
1064
+ return 0
1065
+ if args.dry_run:
1066
+ print(f"[dry-run] would update #{args.issue} Wellenplan status{checked_note}")
1067
+ print(new_table)
1068
+ return 0
1069
+ _write_issue_body(args.issue, new_body)
1070
+ print(f"synced #{args.issue} Wellenplan status from board{checked_note}")
1071
+ return 0
1072
+
1073
+
1074
+ def _add_status_flags(sp) -> None:
1075
+ """The shared --status / --status-role pair (mutually exclusive) on every
1076
+ status-writing subcommand — one wiring point, three subcommands."""
1077
+ g = sp.add_mutually_exclusive_group()
1078
+ g.add_argument("--status", choices=list(STATUS_OPTIONS))
1079
+ g.add_argument("--status-role", dest="status_role", choices=list(STATUS_ROLE_KEYS),
1080
+ help="semantic role resolved via the profile's fields.status.roles")
1081
+
1082
+
1083
+ def build_parser() -> argparse.ArgumentParser:
1084
+ p = argparse.ArgumentParser(prog="board-sync.py", description=__doc__.splitlines()[0])
1085
+ sub = p.add_subparsers(dest="command", required=True)
1086
+
1087
+ sub.add_parser("next-wave", help="print the next monotone wave number").set_defaults(func=cmd_next_wave)
1088
+
1089
+ po = sub.add_parser("parent-of", help="print an issue's parent number or FREI")
1090
+ po.add_argument("issue", type=int)
1091
+ po.set_defaults(func=cmd_parent_of)
1092
+
1093
+ co = sub.add_parser("children-of", help="print an anchor's native sub-issue numbers")
1094
+ co.add_argument("issue", type=int)
1095
+ co.set_defaults(func=cmd_children_of)
1096
+
1097
+ ln = sub.add_parser("link", help="link child as sub-issue of parent (one-parent-checked)")
1098
+ ln.add_argument("--parent", type=int, required=True)
1099
+ ln.add_argument("--child", type=int, required=True)
1100
+ ln.add_argument("--allow-drift-report", action="store_true", dest="allow_drift_report",
1101
+ help="report a foreign-parent conflict but exit 0 (read-only audit)")
1102
+ ln.add_argument("--dry-run", action="store_true")
1103
+ ln.set_defaults(func=cmd_link)
1104
+
1105
+ ul = sub.add_parser("unlink", help="remove child as sub-issue of parent (parent-checked, idempotent)")
1106
+ ul.add_argument("--parent", type=int, required=True)
1107
+ ul.add_argument("--child", type=int, required=True)
1108
+ ul.add_argument("--allow-drift-report", action="store_true", dest="allow_drift_report",
1109
+ help="report a foreign-parent mismatch but exit 0 (read-only audit)")
1110
+ ul.add_argument("--dry-run", action="store_true")
1111
+ ul.set_defaults(func=cmd_unlink)
1112
+
1113
+ dp = sub.add_parser("deps", help="print an issue's native blocked_by edges")
1114
+ dp.add_argument("issue", type=int)
1115
+ dp.add_argument("--json", action="store_true", dest="as_json",
1116
+ help="normalized machine-readable list")
1117
+ dp.set_defaults(func=cmd_deps)
1118
+
1119
+ da = sub.add_parser("dep-add", help="set a native blocked-by edge + body mirror "
1120
+ "(idempotent, self-healing)")
1121
+ da.add_argument("--issue", type=int, required=True, help="the BLOCKED issue")
1122
+ da.add_argument("--blocked-by", type=int, required=True, dest="blocked_by",
1123
+ help="the BLOCKING issue")
1124
+ da.add_argument("--dry-run", action="store_true")
1125
+ da.set_defaults(func=cmd_dep_add)
1126
+
1127
+ dr = sub.add_parser("dep-remove", help="remove a native blocked-by edge + body mirror "
1128
+ "(idempotent)")
1129
+ dr.add_argument("--issue", type=int, required=True, help="the BLOCKED issue")
1130
+ dr.add_argument("--blocked-by", type=int, required=True, dest="blocked_by",
1131
+ help="the BLOCKING issue")
1132
+ dr.add_argument("--dry-run", action="store_true")
1133
+ dr.set_defaults(func=cmd_dep_remove)
1134
+
1135
+ fr = sub.add_parser("frontier", help="buildable frontier of a wave anchor from "
1136
+ "native edges: FREI/BLOCKED/done")
1137
+ fr.add_argument("issue", type=int, help="the wave-anchor issue number")
1138
+ fr.set_defaults(func=cmd_frontier)
1139
+
1140
+ cr = sub.add_parser("create", help="create an issue + add to board + stamp fields")
1141
+ cr.add_argument("--title", required=True)
1142
+ cr.add_argument("--body-file", required=True)
1143
+ cr.add_argument("--label", action="append", help="repeatable")
1144
+ cr.add_argument("--wave", type=int)
1145
+ _add_status_flags(cr)
1146
+ cr.add_argument("--cluster")
1147
+ cr.add_argument("--hitl", action="store_true",
1148
+ help="mark slice HITL — rejects a ready-for-agent label")
1149
+ cr.add_argument("--wave-stub", action="store_true", dest="wave_stub",
1150
+ help="tag as board-to-waves candidate stub (wave-stub label; "
1151
+ "stripped at promote / add --bucket)")
1152
+ cr.add_argument("--dry-run", action="store_true")
1153
+ cr.set_defaults(func=cmd_create)
1154
+
1155
+ pr = sub.add_parser("promote", help="promote a Draft-PRD to Anker (type:cluster + Wave)")
1156
+ pr.add_argument("--issue", type=int, required=True)
1157
+ pr.add_argument("--wave", type=int, required=True,
1158
+ help="explicit Wave (read `next-wave` first — no in-promote race)")
1159
+ _add_status_flags(pr)
1160
+ pr.add_argument("--no-rename", action="store_true",
1161
+ help="keep the title as-is (skip the `Welle <N> — ` prefix)")
1162
+ pr.add_argument("--dry-run", action="store_true")
1163
+ pr.set_defaults(func=cmd_promote)
1164
+
1165
+ ad = sub.add_parser("add", help="add an existing issue to the board + stamp fields")
1166
+ g = ad.add_mutually_exclusive_group(required=True)
1167
+ g.add_argument("--issue", type=int)
1168
+ g.add_argument("--url")
1169
+ ad.add_argument("--wave", type=int)
1170
+ _add_status_flags(ad)
1171
+ ad.add_argument("--cluster")
1172
+ ad.add_argument("--spec-path", dest="spec_path")
1173
+ ad.add_argument("--plan-path", dest="plan_path")
1174
+ ad.add_argument("--bucket", choices=["afk", "hitl"],
1175
+ help="workflow-label write: afk → add ready-for-agent; hitl → strip it")
1176
+ ad.add_argument("--dry-run", action="store_true")
1177
+ ad.set_defaults(func=cmd_add)
1178
+
1179
+ asy = sub.add_parser("anchor-sync",
1180
+ help="regenerate a wave-anchor Slices-table's volatile cells "
1181
+ "(Status/Branch) from the board, in place")
1182
+ asy.add_argument("issue", type=int, help="the wave-anchor issue number")
1183
+ asy.add_argument("--dry-run", action="store_true")
1184
+ asy.set_defaults(func=cmd_anchor_sync)
1185
+
1186
+ vg = sub.add_parser("validate-graph",
1187
+ help="counted Programm-Graph preflight for a Program-PRD "
1188
+ "(read-only)")
1189
+ vg.add_argument("--issue", type=int, required=True, help="the Program-PRD issue number")
1190
+ vg.set_defaults(func=cmd_validate_graph)
1191
+
1192
+ sb = sub.add_parser("stamp-batch",
1193
+ help="alias-batched GraphQL field-stamp (Wave/Phase) for N "
1194
+ "items, chunked ~30 aliases/request")
1195
+ g_sb = sb.add_mutually_exclusive_group(required=True)
1196
+ g_sb.add_argument("--items-file", help="JSON list of {issue,item_id,wave?,phase?}")
1197
+ g_sb.add_argument("--issue", type=int, help="single-item form — also the repair target")
1198
+ sb.add_argument("--item-id", help="required together with --issue")
1199
+ sb.add_argument("--wave", type=int)
1200
+ sb.add_argument("--phase", help="Phase name, resolved via fields.phase.options")
1201
+ sb.add_argument("--dry-run", action="store_true")
1202
+ sb.set_defaults(func=cmd_stamp_batch)
1203
+
1204
+ fv = sub.add_parser("field-value",
1205
+ help="read a project field's current value for an issue "
1206
+ "(— the promote-guard's read side)")
1207
+ fv.add_argument("--issue", type=int, required=True)
1208
+ fv.add_argument("--field", choices=["wave", "phase"], required=True)
1209
+ fv.set_defaults(func=cmd_field_value)
1210
+
1211
+ ps = sub.add_parser("program-sync",
1212
+ help="regenerate a Program-PRD's Wellenplan Status from the "
1213
+ "board, in place (own grammar, not anchor-sync)")
1214
+ ps.add_argument("issue", type=int, help="the Program-PRD issue number")
1215
+ ps.add_argument("--dry-run", action="store_true")
1216
+ ps.set_defaults(func=cmd_program_sync)
1217
+ return p
1218
+
1219
+
1220
+ def main(argv: Optional[list[str]] = None) -> int:
1221
+ args = build_parser().parse_args(argv)
1222
+ try:
1223
+ if getattr(args, "status_role", None):
1224
+ args.status = resolve_status_role(args.status_role, STATUS_ROLES)
1225
+ return args.func(args)
1226
+ except (ValueError, GhError) as exc:
1227
+ print(f"error: {exc}", file=sys.stderr)
1228
+ return 1
1229
+
1230
+
1231
+ if __name__ == "__main__":
1232
+ sys.exit(main())