@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,343 @@
1
+ #!/usr/bin/env python3
2
+ """anchor_table.py — pure Slices-table logic for `board-sync.py anchor-sync`.
3
+
4
+ A wave-anchor issue body carries a Slices-table that duplicates board-tracked
5
+ volatile data. Hand-maintenance drifts (: stale branch, missing split-row).
6
+ `anchor-sync` regenerates the volatile cells IN PLACE: the table stays ONE
7
+ combined table, stable plan columns (Slice/Gate/…) are preserved verbatim, and
8
+ only the volatile cells are refreshed — and only when the board DISAGREES, so a
9
+ no-drift run reproduces the table byte-identically (AC#4) while a drifted cell
10
+ is corrected. Since the canonical table is the lean 6-column form
11
+ `# | Status | Slice | Sub-Issue | Gate | closes/refs` — Status is the only
12
+ volatile column there; legacy anchors with Branch / Blocked-by columns keep
13
+ working because every column is matched by header NAME and refreshed only
14
+ where it exists.
15
+
16
+ This module is PURE — no gh / no I/O / no board config. The gh-backed fetch and
17
+ the command wiring live in board-sync.py; everything here is directly unit-tested
18
+ (scripts/test_board_sync.py) with plain dicts and strings.
19
+
20
+ Columns are keyed by header NAME, not position — anchors carry free-form `#`
21
+ labels (4b/6b) and a varying last column (schließt/refs vs Blocked by).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Optional
27
+
28
+ SLICE_TABLE_START = "<!-- slice-table:start -->"
29
+ SLICE_TABLE_END = "<!-- slice-table:end -->"
30
+
31
+ _SUBISSUE_NUM_RE = re.compile(r"#(\d+)")
32
+ _STATUS_BASE_RE = re.compile(r"^(✅\s*#\d+|🔄|⬜)")
33
+ _STATUS_RANK = {"⬜": 0, "🔄": 1}
34
+
35
+
36
+ # --- markdown pipe-table parse / render --------------------------------------
37
+ def split_pipe_row(line: str) -> list[str]:
38
+ """Cells of a markdown pipe-table row (outer pipes stripped, cells trimmed)."""
39
+ s = line.strip()
40
+ if s.startswith("|"):
41
+ s = s[1:]
42
+ if s.endswith("|"):
43
+ s = s[:-1]
44
+ return [c.strip() for c in s.split("|")]
45
+
46
+
47
+ def is_separator_row(cells: list[str]) -> bool:
48
+ """A `|---|:--:|` header-separator row (every cell only dashes/colons)."""
49
+ return bool(cells) and all(c and set(c) <= set("-: ") for c in cells)
50
+
51
+
52
+ def parse_pipe_table(block_lines: list[str]) -> tuple[list[str], list[list[str]]]:
53
+ """(headers, data_rows) from pipe-table lines. Rows stay as ordered cell-lists
54
+ (not dicts) so extra columns + cell text re-render verbatim. Separator dropped."""
55
+ rows = [split_pipe_row(ln) for ln in block_lines if ln.strip().startswith("|")]
56
+ if not rows:
57
+ return [], []
58
+ return rows[0], [r for r in rows[1:] if not is_separator_row(r)]
59
+
60
+
61
+ def render_pipe_table(headers: list[str], rows: list[list[str]]) -> str:
62
+ """Render a canonical markdown pipe table (single-space padding)."""
63
+ lines = ["| " + " | ".join(headers) + " |",
64
+ "|" + "|".join("---" for _ in headers) + "|"]
65
+ for r in rows:
66
+ cells = (list(r) + [""] * len(headers))[:len(headers)]
67
+ lines.append("| " + " | ".join(cells) + " |")
68
+ return "\n".join(lines)
69
+
70
+
71
+ _MD_EMPHASIS_RE = re.compile(r"^(\*{1,2}|_{1,2})(.+)\1$")
72
+
73
+
74
+ def _strip_markdown_emphasis(cell: str) -> str:
75
+ """Strip one layer of wrapping markdown emphasis (`**Bold**`, `_em_`) so a
76
+ stylised header still exact-matches. Deliberately NOT a fuzzy/substring
77
+ strip — a genuine variant like 'Sub-Issue (native)' must stay unmatched."""
78
+ m = _MD_EMPHASIS_RE.match(cell)
79
+ return m.group(2) if m else cell
80
+
81
+
82
+ def col_index(headers: list[str], name: str) -> Optional[int]:
83
+ """Index of the column whose header — trimmed and stripped of one layer of
84
+ wrapping markdown emphasis — equals name exactly, or None."""
85
+ for i, h in enumerate(headers):
86
+ if _strip_markdown_emphasis(h.strip()) == name:
87
+ return i
88
+ return None
89
+
90
+
91
+ def require_col_index(headers: list[str], name: str) -> int:
92
+ """`col_index(headers, name)`, or a loud ValueError if this column can't be
93
+ pinned down — e.g. a header variant like 'Sub-Issue (native)' that
94
+ `locate_slice_table` matched on a raw substring but `col_index` can't
95
+ exact/tolerant-match. Never let a silent None flow downstream and get
96
+ misread as "no row has this column": that used to make merge_slice_rows
97
+ treat every board sub-issue as missing and append it as a duplicate row,
98
+ on every run (proven by a 2026-07-02 double-run)."""
99
+ idx = col_index(headers, name)
100
+ if idx is None:
101
+ raise ValueError(
102
+ f"slice table found but column {name!r} not matched "
103
+ f"(headers: {headers})")
104
+ return idx
105
+
106
+
107
+ def first_subissue_num(cell: str) -> Optional[int]:
108
+ """First `#<n>` in a cell — the Sub-Issue key, ignoring annotations like
109
+ `(schließt)`."""
110
+ m = _SUBISSUE_NUM_RE.search(cell or "")
111
+ return int(m.group(1)) if m else None
112
+
113
+
114
+ # --- volatile-cell refresh (board → cell) ------------------------------------
115
+ def status_token_from_board(entry: dict, roles: dict) -> str:
116
+ """Canonical Status token from board state: ✅ #<PR> (merged) · 🔄 (open PR or
117
+ an in-flight board status) · ⬜ (otherwise).
118
+
119
+ Which status NAMES count as in-flight comes from the profile's role map
120
+ (`roles["inProgress"]`/`roles["review"]`) — passed in as a plain
121
+ dict so this module stays pure (no board_config import). Empty roles →
122
+ board-status names can't be interpreted (⬜), PR signals still work."""
123
+ prs = entry.get("prs") or []
124
+ merged = [p for p in prs if p.get("state") == "MERGED"]
125
+ if merged:
126
+ return f"✅ #{merged[-1]['number']}"
127
+ active = {roles.get("inProgress"), roles.get("review")} - {None}
128
+ if [p for p in prs if p.get("state") == "OPEN"] or entry.get("status") in active:
129
+ return "🔄"
130
+ return "⬜"
131
+
132
+
133
+ def status_base(cell: str) -> str:
134
+ """Leading canonical token of an existing Status cell (✅ #n / 🔄 / ⬜), or ''."""
135
+ m = _STATUS_BASE_RE.match((cell or "").strip())
136
+ return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
137
+
138
+
139
+ def _status_rank(base: str) -> int:
140
+ """Monotone progress rank of a Status base token: ⬜ < 🔄 < ✅; '' = lowest."""
141
+ if base.startswith("✅"):
142
+ return 2
143
+ return _STATUS_RANK.get(base, -1)
144
+
145
+
146
+ def refresh_status_cell(existing: str, entry: dict, roles: dict) -> str:
147
+ """Refresh a Status cell from the board, MONOTONICALLY (never regress).
148
+
149
+ Keep the existing cell verbatim when the board agrees (preserves hand
150
+ annotations like `(gen-a)`) OR when the board token would regress it — gen-a/gen-b
151
+ slices `Part of` the anchor stay OPEN while a part merges, so the board shows no
152
+ closing PR; a human ✅ must not flip back to ⬜. Only an advance (⬜→🔄→✅, or a
153
+ corrected merge-PR number) replaces the cell."""
154
+ token = status_token_from_board(entry, roles)
155
+ base = status_base(existing)
156
+ if _status_rank(token) < _status_rank(base):
157
+ return existing
158
+ return existing if base == token else token
159
+
160
+
161
+ def branch_from_board(entry: dict) -> Optional[str]:
162
+ """headRefName of the relevant linked PR (merged > open > any), or None."""
163
+ prs = entry.get("prs") or []
164
+ chosen = ([p for p in prs if p.get("state") == "MERGED"]
165
+ or [p for p in prs if p.get("state") == "OPEN"] or prs)
166
+ return chosen[-1].get("headRefName") if chosen else None
167
+
168
+
169
+ def refresh_branch_cell(existing: str, entry: dict) -> str:
170
+ """Replace the branch with the linked PR's head once a PR exists and it differs;
171
+ keep verbatim when it agrees (preserves backticks) or when there is no PR yet
172
+ (the planned branch stays hand-owned)."""
173
+ head = branch_from_board(entry)
174
+ if not head:
175
+ return existing
176
+ if (existing or "").strip().strip("`").strip() == head:
177
+ return existing
178
+ return f"`{head}`"
179
+
180
+
181
+ def render_blocked_cell(blocked_by: list) -> str:
182
+ """Canonical `Blocked by` cell from native edges: open plain, closed with ✓,
183
+ none → `—`. Machine-owned since (native dependencies are the SSOT)."""
184
+ if not blocked_by:
185
+ return "—"
186
+ ordered = ([b for b in blocked_by if b.get("state") == "open"]
187
+ + [b for b in blocked_by if b.get("state") != "open"])
188
+ return ", ".join(f"#{b['number']}" + ("" if b.get("state") == "open" else " ✓")
189
+ for b in ordered)
190
+
191
+
192
+ def refresh_blocked_cell(existing: str, entry: dict) -> str:
193
+ """Refresh a `Blocked by` cell from the entry's native edges.
194
+
195
+ Edges exist → the cell is machine-owned: replace unless already canonical.
196
+ No edges → clear only a cell that still carries stale `#n` refs; a plain
197
+ `—`/empty/prose cell stays verbatim (byte-stable no-drift runs)."""
198
+ blocked_by = entry.get("blocked_by")
199
+ if blocked_by is None:
200
+ return existing
201
+ canonical = render_blocked_cell(blocked_by)
202
+ if not blocked_by and not _SUBISSUE_NUM_RE.search(existing or ""):
203
+ return existing
204
+ return existing if (existing or "").strip() == canonical else canonical
205
+
206
+
207
+ def merge_slice_rows(headers: list[str], rows: list[list[str]],
208
+ board: dict, roles: dict) -> tuple[list[list[str]], list[int]]:
209
+ """Refresh Status/Branch per row from board (keyed by the Sub-Issue cell's first
210
+ #n) and append board sub-issues missing from the table. Returns (rows, appended).
211
+ Rows whose sub-issue is absent from the board stay verbatim.
212
+
213
+ Sub-Issue and Status are REQUIRED columns (`require_col_index` raises loudly
214
+ if a header variant can't be matched) — a silent None here used to make
215
+ `seen` stay empty and every board sub-issue get appended as a duplicate row
216
+ on every run."""
217
+ si = require_col_index(headers, "Sub-Issue")
218
+ st = require_col_index(headers, "Status")
219
+ br = col_index(headers, "Branch")
220
+ bl = col_index(headers, "Blocked by")
221
+ slice_i = col_index(headers, "Slice")
222
+ # A board sub-issue counts as present if its #n appears ANYWHERE in the
223
+ # Sub-Issue column — incl. a folded secondary ref like `(schließt)`
224
+ # — so it is never appended as a duplicate row.
225
+ seen: set = set()
226
+ for row in rows:
227
+ if si < len(row):
228
+ seen.update(int(n) for n in _SUBISSUE_NUM_RE.findall(row[si]))
229
+ out: list[list[str]] = []
230
+ for row in rows:
231
+ row = list(row)
232
+ sub = first_subissue_num(row[si]) if si < len(row) else None
233
+ if sub is not None and sub in board:
234
+ if st < len(row):
235
+ row[st] = refresh_status_cell(row[st], board[sub], roles)
236
+ if br is not None and br < len(row):
237
+ row[br] = refresh_branch_cell(row[br], board[sub])
238
+ if bl is not None and bl < len(row):
239
+ row[bl] = refresh_blocked_cell(row[bl], board[sub])
240
+ out.append(row)
241
+ appended: list[int] = []
242
+ for sub, entry in board.items():
243
+ if sub in seen:
244
+ continue
245
+ new = [""] * len(headers)
246
+ new[si] = f"#{sub}"
247
+ new[st] = status_token_from_board(entry, roles)
248
+ if br is not None:
249
+ head = branch_from_board(entry)
250
+ if head:
251
+ new[br] = f"`{head}`"
252
+ if bl is not None and entry.get("blocked_by") is not None:
253
+ new[bl] = render_blocked_cell(entry["blocked_by"])
254
+ if slice_i is not None:
255
+ new[slice_i] = entry.get("title", "")
256
+ out.append(new)
257
+ appended.append(sub)
258
+ return out, appended
259
+
260
+
261
+ # --- locate / splice the table within the anchor body ------------------------
262
+ def locate_slice_table(lines: list[str]) -> Optional[tuple[int, int]]:
263
+ """[start, end) of the existing slice table: the header pipe-row that names both
264
+ Status and Sub-Issue, plus the contiguous pipe rows after it.
265
+
266
+ Deliberately a raw substring match (lenient, to find variant tables) — that
267
+ can locate a header whose actual Sub-Issue/Status columns don't `col_index`
268
+ exact-match (e.g. 'Sub-Issue (native)'). That mismatch is caught downstream:
269
+ `merge_slice_rows` requires both columns via `require_col_index`, which
270
+ raises loudly instead of silently duplicating rows."""
271
+ hdr = next((i for i, ln in enumerate(lines)
272
+ if ln.strip().startswith("|") and "Status" in ln and "Sub-Issue" in ln), None)
273
+ if hdr is None:
274
+ return None
275
+ end = hdr + 1
276
+ while end < len(lines) and lines[end].strip().startswith("|"):
277
+ end += 1
278
+ return hdr, end
279
+
280
+
281
+ def current_slice_table(body: str) -> tuple[list[str], list[list[str]]]:
282
+ """Parse the live slice table — between markers if present, else the located block."""
283
+ if SLICE_TABLE_START in body and SLICE_TABLE_END in body:
284
+ inner = body.split(SLICE_TABLE_START, 1)[1].split(SLICE_TABLE_END, 1)[0]
285
+ return parse_pipe_table(inner.splitlines())
286
+ bounds = locate_slice_table(body.splitlines())
287
+ if bounds is None:
288
+ return [], []
289
+ return parse_pipe_table(body.splitlines()[bounds[0]:bounds[1]])
290
+
291
+
292
+ def splice_slice_table(body: str, table_str: str) -> str:
293
+ """Splice the rendered table between the markers, leaving the rest of the body
294
+ untouched. On the first run (no markers) the existing table block is located and
295
+ replaced with a marked block. Raises if no slice table is found."""
296
+ block = f"{SLICE_TABLE_START}\n{table_str}\n{SLICE_TABLE_END}"
297
+ if SLICE_TABLE_START in body and SLICE_TABLE_END in body:
298
+ pat = re.compile(re.escape(SLICE_TABLE_START) + r".*?" + re.escape(SLICE_TABLE_END),
299
+ re.DOTALL)
300
+ return pat.sub(lambda _m: block, body)
301
+ lines = body.splitlines()
302
+ bounds = locate_slice_table(lines)
303
+ if bounds is None:
304
+ raise ValueError("no slice table found in anchor body "
305
+ "(needs a header row naming both Status and Sub-Issue)")
306
+ start, end = bounds
307
+ result = "\n".join(lines[:start] + block.splitlines() + lines[end:])
308
+ return result + "\n" if body.endswith("\n") else result
309
+
310
+
311
+ def extract_anchor_board_data(data: dict, status_field_id: str) -> dict:
312
+ """{sub-issue number: {title, status, prs:[{number,state,headRefName}]}} from the
313
+ anchor sub-issues GraphQL response.
314
+
315
+ The status is matched by FIELD ID (`status_field_id` from the profile),
316
+ not by the display name "Status" — a consumer board may call the field
317
+ anything (mirrors the SessionStart hook's field-ID match)."""
318
+ issue = (data.get("data") or {}).get("repository", {}).get("issue")
319
+ nodes = ((issue or {}).get("subIssues") or {}).get("nodes") or []
320
+ out: dict = {}
321
+ for n in nodes:
322
+ prs = ((n.get("closedByPullRequestsReferences") or {}).get("nodes")) or []
323
+ status = None
324
+ for it in ((n.get("projectItems") or {}).get("nodes") or []):
325
+ for fv in ((it.get("fieldValues") or {}).get("nodes") or []):
326
+ if (fv.get("field") or {}).get("id") == status_field_id and fv.get("name"):
327
+ status = fv["name"]
328
+ break
329
+ if status:
330
+ break
331
+ blocked = ((n.get("blockedBy") or {}).get("nodes")) or []
332
+ out[n["number"]] = {
333
+ "title": n.get("title", ""),
334
+ "state": (n.get("state") or "").lower(),
335
+ "status": status,
336
+ "prs": [{"number": p["number"], "state": p["state"],
337
+ "headRefName": p.get("headRefName")} for p in prs],
338
+ # GraphQL state is UPPERCASE; normalize to the REST lowercase the
339
+ # dependency helpers use everywhere else.
340
+ "blocked_by": [{"number": b["number"],
341
+ "state": (b.get("state") or "").lower()} for b in blocked],
342
+ }
343
+ return out