@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,256 @@
1
+ #!/usr/bin/env python3
2
+ """program_graph_validate.py — the 8 Programm-Graph validation axes.
3
+
4
+ Structural (blocking): Zyklen, Rückwärts-Refs über Wellengrenzen, Kapazität,
5
+ Phasen-Optionen, Revisions-Kohärenz. Advisory (counted, shown, non-blocking):
6
+ Gate-Slice-Struktur-Verdacht, Rollup-Kette-Lücken, Scope-Abdeckung-Lücken — these
7
+ feed the counted Vorschau-Gate report, but a human decides whether an incomplete
8
+ draft proceeds; only structural errors are hard.
9
+
10
+ PURE — no gh / no I/O / no board_config. `phase_options` is injected as a plain
11
+ parameter (caller reads it defensively from the board profile CR#1).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from program_graph_parse import ProgramGraph, SliceBlock, WaveRow
18
+
19
+ # Gate-Legende (docs/agents/wave-anchor-template.md): "—" = AFK/no gate; the four
20
+ # non-AFK tags mark a Gate-Slice (Struktur-Verdacht check).
21
+ GATE_TYPES = {"🧭", "🔬", "📐", "📝"}
22
+
23
+
24
+ def find_cycles(slices: list[SliceBlock]) -> list[list[str]]:
25
+ """Cycles in the blocked_by graph, each as the ordered path incl. the repeated
26
+ closing id. Unknown targets are ignored here (see `find_unknown_refs`)."""
27
+ ids = {s.local_id for s in slices}
28
+ graph = {s.local_id: [b for b in s.blocked_by if b in ids] for s in slices}
29
+ WHITE, GRAY, BLACK = 0, 1, 2
30
+ color = {k: WHITE for k in graph}
31
+ cycles: list[list[str]] = []
32
+ path: list[str] = []
33
+
34
+ def dfs(node: str) -> None:
35
+ color[node] = GRAY
36
+ path.append(node)
37
+ for nxt in graph.get(node, []):
38
+ if color[nxt] == GRAY:
39
+ idx = path.index(nxt)
40
+ cycles.append(path[idx:] + [nxt])
41
+ elif color[nxt] == WHITE:
42
+ dfs(nxt)
43
+ path.pop()
44
+ color[node] = BLACK
45
+
46
+ for node in graph:
47
+ if color[node] == WHITE:
48
+ dfs(node)
49
+ return cycles
50
+
51
+
52
+ def find_duplicate_slice_ids(slices: list[SliceBlock]) -> list[str]:
53
+ """Duplicate `local_id`s make the slice graph ambiguous: find_cycles,
54
+ find_backward_refs and the rollup membership dict all key slices by
55
+ local_id (last-wins dict lookups), so a second `#### 1a` block silently
56
+ shadows the first block's blocked_by edges — a blocking cycle living in the
57
+ shadowed block would otherwise go undetected. Same structural error class
58
+ as a cycle (Bug 3)."""
59
+ seen: set[str] = set()
60
+ dupes: list[str] = []
61
+ for s in slices:
62
+ if s.local_id in seen and s.local_id not in dupes:
63
+ dupes.append(s.local_id)
64
+ seen.add(s.local_id)
65
+ return [f"doppelte Slice-ID '{lid}' im Wellenplan — IDs müssen eindeutig sein"
66
+ for lid in dupes]
67
+
68
+
69
+ def find_unknown_refs(slices: list[SliceBlock]) -> list[str]:
70
+ """blocked_by targets that name no existing slice local-id."""
71
+ ids = {s.local_id for s in slices}
72
+ return [f"Slice {s.local_id}: blocked_by unbekannte Referenz '{b}'"
73
+ for s in slices for b in s.blocked_by if b not in ids]
74
+
75
+
76
+ def find_backward_refs(slices: list[SliceBlock]) -> list[str]:
77
+ """blocked_by a LATER wave's slice = a backward ref across the wave boundary
78
+ (a slice may only depend on its own or an earlier wave). Blocking."""
79
+ by_id = {s.local_id: s for s in slices}
80
+ out = []
81
+ for s in slices:
82
+ if s.wave is None:
83
+ continue
84
+ for b in s.blocked_by:
85
+ target = by_id.get(b)
86
+ if target is None or target.wave is None:
87
+ continue
88
+ if target.wave > s.wave:
89
+ out.append(
90
+ f"Slice {s.local_id} (Welle {s.wave}) blockiert durch {b} "
91
+ f"(Welle {target.wave}) — Rückwärts-Ref über Wellengrenze")
92
+ return out
93
+
94
+
95
+ def find_gate_dependents_outside_wave(slices: list[SliceBlock]) -> list[str]:
96
+ """A Gate-Slice (🧭/🔬/📐/📝) with dependents in a DIFFERENT wave is a
97
+ structural suspicion — gate-slices are meant to be wave-local (AFK-safe)."""
98
+ warnings = []
99
+ for s in slices:
100
+ if s.gate not in GATE_TYPES:
101
+ continue
102
+ outside = [o for o in slices if s.local_id in o.blocked_by and o.wave != s.wave]
103
+ if outside:
104
+ names = ", ".join(f"{o.local_id} (Welle {o.wave})" for o in outside)
105
+ warnings.append(
106
+ f"Gate-Slice {s.local_id} (Welle {s.wave}) hat wellen-fremde "
107
+ f"Abhängige: {names} — Struktur-Verdacht")
108
+ return warnings
109
+
110
+
111
+ def check_capacity(waves: list[WaveRow], max_children: int = 100) -> list[str]:
112
+ """GitHub's 100-children-per-parent limit, for the PRD→Welle + Welle→Slice fan-outs."""
113
+ errors = []
114
+ if len(waves) > max_children:
115
+ errors.append(f"{len(waves)} Wellen im PRD > {max_children} "
116
+ f"(GitHub Sub-Issue-Limit je Parent)")
117
+ for w in waves:
118
+ if len(w.slice_ids) > max_children:
119
+ errors.append(f"Welle {w.number}: {len(w.slice_ids)} Slices > "
120
+ f"{max_children} (GitHub Sub-Issue-Limit je Parent)")
121
+ return errors
122
+
123
+
124
+ def check_phase_options(graph: ProgramGraph, phase_options: Optional[list[str]]) -> list[str]:
125
+ """Every Phase value used (Wellenplan rows or Slice metadata) must be a
126
+ configured option of the board's Phase field. `phase_options=None` means the
127
+ profile has no `fields.phase` at all — a visible setup hint, never a crash
128
+ (CRITICAL RECONCILIATION #2), and only if the PRD uses phases at all
129
+ (a phase-less PRD is unaffected — Phasen sind optional)."""
130
+ used = {w.phase for w in graph.waves if w.phase}
131
+ used |= {s.phase for s in graph.slices if s.phase}
132
+ if not used:
133
+ return []
134
+ if phase_options is None:
135
+ return ["keine Phase-Feld-Konfiguration im Board-Profil gefunden — "
136
+ "Setup-Hinweis: `fields.phase` ergänzen (/setup-workflow)"]
137
+ unknown = sorted(p for p in used if p not in phase_options)
138
+ return [f"Phase '{p}' ist keine Option des Phase-Felds im Board-Profil "
139
+ f"— Setup-Hinweis: Option in /setup-workflow ergänzen" for p in unknown]
140
+
141
+
142
+ def _check_slice_membership(graph: ProgramGraph) -> list[str]:
143
+ """Each Slices-column id must have a Slice-block; each Slice-block must be
144
+ referenced by exactly one wave and belong to a wave that exists."""
145
+ gaps: list[str] = []
146
+ wave_by_number = {w.number: w for w in graph.waves}
147
+ membership: dict[str, int] = {s.local_id: 0 for s in graph.slices}
148
+ for w in graph.waves:
149
+ for sid in w.slice_ids:
150
+ if sid in membership:
151
+ membership[sid] += 1
152
+ else:
153
+ gaps.append(f"Welle {w.number}: Slice '{sid}' in der Slices-Spalte "
154
+ f"hat keinen Slice-Block")
155
+ for sid, count in membership.items():
156
+ if count == 0:
157
+ gaps.append(f"Slice {sid}: in keiner Wellenplan-Zeile referenziert")
158
+ elif count > 1:
159
+ gaps.append(f"Slice {sid}: in {count} Wellenplan-Zeilen referenziert "
160
+ f"(erwartet genau 1)")
161
+ for s in graph.slices:
162
+ if s.wave is None:
163
+ gaps.append(f"Slice {s.local_id}: kein wave-Feld gesetzt")
164
+ elif s.wave not in wave_by_number:
165
+ gaps.append(f"Slice {s.local_id}: Welle {s.wave} existiert nicht im Wellenplan")
166
+ return gaps
167
+
168
+
169
+ def _check_wave_completeness(graph: ProgramGraph) -> list[str]:
170
+ """Every wave carries ≥1 slice + a Gate (+ a Phase if the PRD uses phases)."""
171
+ gaps: list[str] = []
172
+ phases_used = any(w.phase for w in graph.waves)
173
+ for w in graph.waves:
174
+ if not w.slice_ids:
175
+ gaps.append(f"Welle {w.number}: keine Slices")
176
+ if not w.gate.strip():
177
+ gaps.append(f"Welle {w.number}: kein Gate-Wert gesetzt")
178
+ if phases_used and not w.phase:
179
+ gaps.append(f"Welle {w.number}: keine Phase gesetzt, obwohl das "
180
+ f"Programm Phasen nutzt")
181
+ return gaps
182
+
183
+
184
+ def _check_phase_gate_pairing(graph: ProgramGraph) -> list[str]:
185
+ """Every used Phase has a checklist Gate-Kriterium, and every checklist entry
186
+ names a Phase some wave uses."""
187
+ gaps: list[str] = []
188
+ phases_used = {w.phase for w in graph.waves if w.phase}
189
+ for phase in phases_used:
190
+ if phase not in graph.phase_gates:
191
+ gaps.append(f"Phase {phase}: kein Gate-Kriterium in der "
192
+ f"Phasen-Gates-Checkliste")
193
+ for phase in graph.phase_gates:
194
+ if phase not in phases_used:
195
+ gaps.append(f"Phase {phase}: keine Welle referenziert diese Phase "
196
+ f"(Checklisten-Waise)")
197
+ return gaps
198
+
199
+
200
+ def check_rollup(graph: ProgramGraph) -> list[str]:
201
+ """Vertical completeness: the Programm→Welle→Slice rollup chain is closed
202
+ (slice↔wave membership + per-wave completeness + phase↔gate pairing)."""
203
+ return [*_check_slice_membership(graph), *_check_wave_completeness(graph),
204
+ *_check_phase_gate_pairing(graph)]
205
+
206
+
207
+ def check_scope_coverage(graph: ProgramGraph) -> tuple[int, int, list[str]]:
208
+ """Horizontal completeness: every Scope-Item is covered by ≥1 wave, every wave
209
+ covers ≥1 Scope-Item or is explicitly marked `enabler`, and every covers token
210
+ names a declared Scope-Item (an `S99` typo is a non-blocking gap of its own —
211
+ advisory, and doesn't affect the covered/total count below). Returns
212
+ (covered_count, total_count, gap_messages)."""
213
+ gaps: list[str] = []
214
+ covered_ids: set = set()
215
+ for w in graph.waves:
216
+ if not w.covers and not w.is_enabler:
217
+ gaps.append(f"Welle {w.number}: keine Scope-Abdeckung und nicht als "
218
+ f"Enabler markiert")
219
+ for token in w.covers:
220
+ if token not in graph.scope_items:
221
+ gaps.append(f"Welle {w.number}: covers unbekanntes Scope-Item "
222
+ f"'{token}'")
223
+ covered_ids.update(w.covers)
224
+ covered = 0
225
+ for sid in graph.scope_items:
226
+ if sid in covered_ids:
227
+ covered += 1
228
+ else:
229
+ gaps.append(f"Scope-Item {sid}: von keiner Welle abgedeckt")
230
+ return covered, len(graph.scope_items), gaps
231
+
232
+
233
+ def check_revision_coherence(plan_revision: Optional[int],
234
+ stub_revisions: Optional[list[dict]]) -> list[str]:
235
+ """A published Welle-Stub whose `<!-- program-revision: rN -->` marker (stamped
236
+ by `to-waves`) no longer matches the PRD's plan_revision is stale — it blocks
237
+ loudly instead of silently building from an outdated plan.
238
+
239
+ `stub_revisions` (`[{"label": str, "revision": int | None}, …]`) is pre-fetched
240
+ by the caller (this module stays I/O-free); the CLI passes `[]` when no stubs
241
+ are fetched pre-publish, and callers wire in the real fetch via the same
242
+ signature."""
243
+ if not stub_revisions:
244
+ return []
245
+ if plan_revision is None:
246
+ return ["PRD hat kein plan_revision — Revisions-Kohärenz nicht prüfbar"]
247
+ out = []
248
+ for stub in stub_revisions:
249
+ label = stub.get("label", "?")
250
+ rev = stub.get("revision")
251
+ if rev is None:
252
+ out.append(f"{label}: kein program-revision-Marker gefunden")
253
+ elif rev != plan_revision:
254
+ out.append(f"{label}: program-revision r{rev} != PRD plan_revision "
255
+ f"r{plan_revision} — stale")
256
+ return out
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """program_sync.py — Wellenplan Status-resync for `board-sync.py program-sync`
3
+ (plan step 9b(d); Status column amendment).
4
+
5
+ A Program-PRD's `## Wellenplan` table is its OWN grammar (Welle/Status/Name/
6
+ Slices/Gate/covers — parsed by `program_graph_parse.py`), one level above a
7
+ Welle-Anchor's own Slices-table. `anchor-sync` (`anchor_table.py`) already
8
+ regenerates an Anchor's Slice-row Status/Branch from its native sub-issues;
9
+ `program-sync` does the SAME KIND of thing one graph level up — a PRD's native
10
+ sub-issues are its promoted Wave-Anchor stubs — but deliberately as a SEPARATE
11
+ command over a SEPARATE grammar (plan 9b(d): "program-sync statt
12
+ anchor-sync-Überladung"), not a second table format bolted onto either module.
13
+
14
+ `WaveRow` carries a dedicated `status` field (docs/adr/0054 §Folge-Arbeit forbids
15
+ only a SECOND Wellenplan table format, not a Status COLUMN within the one
16
+ grammar). The volatile Status therefore lives in its own cell, refreshed
17
+ MONOTONICALLY (never ✅→🔄→⬜, mirroring `anchor_table.refresh_status_cell`'s
18
+ never-regress rule) — the `Name` cell is left entirely untouched by this sync:
19
+ hand annotations there always survive verbatim, and Status is the only column
20
+ `program-sync` ever rewrites.
21
+
22
+ PURE — no gh / no I/O / no board_config. Only imports `WaveRow` from
23
+ `program_graph`'s public facade (never re-implementing the Wellenplan
24
+ parser/renderer — `program_graph_parse.py` owns that grammar).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import replace
30
+ from typing import Optional
31
+
32
+ from program_graph import WaveRow
33
+
34
+ _WAVE_NUMBER_RE = re.compile(r"^\s*Welle\s+(\d+)\s*[—–-]", re.IGNORECASE)
35
+ _STATUS_RANK = {"⬜": 0, "🔄": 1, "✅": 2}
36
+
37
+ _WELLENPLAN_BLOCK_RE = re.compile(
38
+ r"<!--\s*wellenplan:start\s*-->.*?<!--\s*wellenplan:end\s*-->", re.DOTALL)
39
+
40
+
41
+ def extract_wave_number_from_title(title: str) -> Optional[int]:
42
+ """The wave number out of a promoted Wave-Anchor's `Welle <N> — <Thema>`
43
+ title (the same convention `board-sync.py`'s `wave_title()` writes), or
44
+ None for a title that isn't wave-prefixed (not yet promoted / foreign)."""
45
+ m = _WAVE_NUMBER_RE.match(title or "")
46
+ return int(m.group(1)) if m else None
47
+
48
+
49
+ def wave_status_token(entry: Optional[dict], roles: dict) -> str:
50
+ """⬜ (no matching native child yet — not promoted) · 🔄 (an in-flight
51
+ board status) · ✅ (the done-role status). Deliberately NOT
52
+ `anchor_table.status_token_from_board` — a Wave-Anchor issue is never
53
+ `closes`-referenced by a PR (board-sync.md convention: `closes` never
54
+ targets an Anker), so a Wave's completion is read off its OWN board Status
55
+ field, not a merged-PR check.
56
+
57
+ Status NAMES come from the profile's role map (`roles["done"]`,
58
+ `roles["inProgress"]`/`roles["review"]`), passed as a plain dict so
59
+ this module stays pure. Empty roles → every status reads as ⬜ (the
60
+ monotone refresh never regresses a hand-set cell)."""
61
+ if entry is None:
62
+ return "⬜"
63
+ status = entry.get("status")
64
+ if status and status == roles.get("done"):
65
+ return "✅"
66
+ if status and status in ({roles.get("inProgress"), roles.get("review")} - {None}):
67
+ return "🔄"
68
+ return "⬜"
69
+
70
+
71
+ def refresh_wave_status(existing_status: str, token: str) -> str:
72
+ """Refresh a Wellenplan row's Status cell from the board, MONOTONICALLY
73
+ (never regress ✅→🔄→⬜ — mirrors `anchor_table.refresh_status_cell`'s
74
+ never-regress rule). An existing cell that isn't a known token (unset,
75
+ blank, or hand-typed garbage) ranks below ⬜ (rank -1), so the first sync
76
+ always sets it."""
77
+ existing_rank = _STATUS_RANK.get(existing_status, -1)
78
+ token_rank = _STATUS_RANK.get(token, -1)
79
+ if token_rank < existing_rank:
80
+ return existing_status
81
+ return existing_status if existing_status == token else token
82
+
83
+
84
+ def sync_wellenplan_status(waves: list[WaveRow], board: dict, roles: dict) -> list[WaveRow]:
85
+ """A fresh `WaveRow` list with each row's `status` field refreshed from
86
+ `board` — the SAME `{issue: {title, status, prs}}` shape
87
+ `anchor_table.extract_anchor_board_data` already produces (no second query,
88
+ no second parse: the PRD's native sub-issues — its promoted Wave-Anchor
89
+ stubs — are fetched exactly like an Anchor fetches its Slice sub-issues).
90
+ The `issue` field (the navigation link to the wave's own issue) is
91
+ FILLED from the matching board child but never overwritten — once stamped
92
+ it is identity, a later title rename must not re-point it. Every other
93
+ WaveRow field (Name/Phase/Slices/Gate/covers/is_enabler) round-trips
94
+ untouched — those are stable Plan columns (or, for Name, hand-owned prose),
95
+ never board-derived."""
96
+ by_wave_number: dict[int, tuple[int, dict]] = {}
97
+ for issue_no, entry in board.items():
98
+ n = extract_wave_number_from_title(entry.get("title", ""))
99
+ if n is not None:
100
+ by_wave_number[n] = (issue_no, entry)
101
+ out: list[WaveRow] = []
102
+ for w in waves:
103
+ hit = by_wave_number.get(w.number)
104
+ entry = hit[1] if hit else None
105
+ issue = w.issue if w.issue is not None else (hit[0] if hit else None)
106
+ out.append(replace(w, issue=issue,
107
+ status=refresh_wave_status(w.status, wave_status_token(entry, roles))))
108
+ return out
109
+
110
+
111
+ _PHASE_GATES_HEADING_RE = re.compile(r"^##\s+Phasen-Gates\s*$")
112
+ _H2_LINE_RE = re.compile(r"^##\s+")
113
+ _UNCHECKED_GATE_RE = re.compile(r"^(\s*-\s+\[) (\]\s*(P\d+):.*)$")
114
+
115
+
116
+ def checkoff_phase_gates(body: str, waves: list[WaveRow], today: str) -> tuple[str, list[str]]:
117
+ """Check off `## Phasen-Gates` entries whose phase is mechanically complete —
118
+ every Wellenplan wave carrying that phase has status ✅ (upward
119
+ propagation). Monotone + idempotent: only an UNCHECKED box whose phase just
120
+ completed is flipped (`[ ]` → `[x]`, with an `— alle Wellen ✅ (<today>)`
121
+ suffix stamped once); a checked box is never touched again, a phase no wave
122
+ uses is never checked (that is a Checklisten-Waise — validate-graph's
123
+ finding, not ours). Only lines inside the `## Phasen-Gates` section are
124
+ considered. Returns (new_body, newly_checked_phase_ids)."""
125
+ waves_by_phase: dict[str, list[WaveRow]] = {}
126
+ for w in waves:
127
+ if w.phase:
128
+ waves_by_phase.setdefault(w.phase, []).append(w)
129
+ done_phases = {p for p, ws in waves_by_phase.items()
130
+ if all(w.status == "✅" for w in ws)}
131
+ lines = body.splitlines()
132
+ in_section = False
133
+ checked: list[str] = []
134
+ for i, ln in enumerate(lines):
135
+ if _PHASE_GATES_HEADING_RE.match(ln):
136
+ in_section = True
137
+ continue
138
+ if in_section and _H2_LINE_RE.match(ln):
139
+ in_section = False
140
+ if not in_section:
141
+ continue
142
+ m = _UNCHECKED_GATE_RE.match(ln)
143
+ if m and m.group(3) in done_phases:
144
+ lines[i] = f"{m.group(1)}x{m.group(2)} — alle Wellen ✅ ({today})"
145
+ checked.append(m.group(3))
146
+ new_body = "\n".join(lines) + ("\n" if body.endswith("\n") else "")
147
+ return new_body, checked
148
+
149
+
150
+ def splice_wellenplan_table(body: str, rendered_block: str) -> str:
151
+ """Replace the `<!-- wellenplan:start/end -->` block with a freshly
152
+ rendered one (`program_graph.render_wellenplan_table`'s output already
153
+ carries its own markers). Mirrors `anchor_table.splice_slice_table`'s
154
+ marker-replace mechanic — kept HERE rather than shared with it, because
155
+ this is a different table grammar entirely (plan 9b(d))."""
156
+ if not _WELLENPLAN_BLOCK_RE.search(body):
157
+ raise ValueError(
158
+ "no Wellenplan table found in program body (needs "
159
+ "`<!-- wellenplan:start -->`/`<!-- wellenplan:end -->` markers)")
160
+ return _WELLENPLAN_BLOCK_RE.sub(lambda _m: rendered_block, body)
@@ -0,0 +1,96 @@
1
+ /** Block shipped changes whose version or checked manifest does not match a fresh build. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { buildKit } from './build-kit.mjs';
8
+
9
+ const byPath = (manifest) => new Map(manifest.files.map((entry) => [entry.path, entry.sha256]));
10
+
11
+ export function manifestDelta(before, after) {
12
+ const old = byPath(before);
13
+ const fresh = byPath(after);
14
+ return {
15
+ added: [...fresh.keys()].filter((path) => !old.has(path)).sort(),
16
+ removed: [...old.keys()].filter((path) => !fresh.has(path)).sort(),
17
+ changed: [...fresh.keys()].filter((path) => old.has(path) && old.get(path) !== fresh.get(path)).sort(),
18
+ };
19
+ }
20
+
21
+ const hasDelta = (delta) => Object.values(delta).some((paths) => paths.length);
22
+ const describe = (delta) => ['added', 'removed', 'changed']
23
+ .filter((kind) => delta[kind].length)
24
+ .map((kind) => `${kind}: ${delta[kind].join(', ')}`).join('; ');
25
+
26
+ export function recommendBump(delta) {
27
+ if (delta.removed.length) return 'major';
28
+ if (delta.added.length) return 'minor';
29
+ return delta.changed.length ? 'patch' : null;
30
+ }
31
+
32
+ function bumpKind(before, after) {
33
+ const a = before.split('.').map(Number);
34
+ const b = after.split('.').map(Number);
35
+ if (b[0] > a[0]) return 'major';
36
+ if (b[0] === a[0] && b[1] > a[1]) return 'minor';
37
+ if (b[0] === a[0] && b[1] === a[1] && b[2] > a[2]) return 'patch';
38
+ return null;
39
+ }
40
+
41
+ export function assessRelease(input) {
42
+ const delta = manifestDelta(input.baseManifest, input.builtManifest);
43
+ const checkedDrift = manifestDelta(input.checkedManifest, input.builtManifest);
44
+ const errors = [];
45
+ if (hasDelta(delta) && input.currentVersion === input.baseVersion) {
46
+ errors.push(`shipped delta has no version bump (${describe(delta)}); version remains ${input.currentVersion}`);
47
+ }
48
+ if (input.checkedManifest.kitVersion !== input.currentVersion) {
49
+ errors.push(`checked manifest version ${input.checkedManifest.kitVersion} != package ${input.currentVersion}`);
50
+ }
51
+ for (const path of checkedDrift.removed) errors.push(`checked manifest dead entry: ${path}`);
52
+ if (checkedDrift.added.length || checkedDrift.changed.length) {
53
+ errors.push(`checked manifest is stale (${describe(checkedDrift)})`);
54
+ }
55
+ const recommendedBump = recommendBump(delta);
56
+ const actual = bumpKind(input.baseVersion, input.currentVersion);
57
+ const rank = { patch: 1, minor: 2, major: 3 };
58
+ if (hasDelta(delta) && input.currentVersion !== input.baseVersion && !actual) {
59
+ errors.push(`invalid version transition: ${input.baseVersion} -> ${input.currentVersion}`);
60
+ }
61
+ if (recommendedBump && actual && rank[actual] < rank[recommendedBump]) {
62
+ errors.push(`${actual} bump is smaller than recommended ${recommendedBump} (${describe(delta)})`);
63
+ }
64
+ return { ok: errors.length === 0, errors, delta, recommendedBump };
65
+ }
66
+
67
+ function gitShowJson(repoRoot, ref, path) {
68
+ return JSON.parse(execFileSync('git', ['show', `${ref}:${path}`], { cwd: repoRoot, encoding: 'utf8' }));
69
+ }
70
+
71
+ export async function checkReleaseDelta({ repoRoot, baseRef = 'origin/main' } = {}) {
72
+ repoRoot ??= join(dirname(fileURLToPath(import.meta.url)), '..');
73
+ const distDir = await mkdtemp(join(tmpdir(), 'awkit-release-guard-'));
74
+ try {
75
+ await buildKit({ repoRoot, distDir });
76
+ const currentPackage = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
77
+ return assessRelease({
78
+ baseVersion: gitShowJson(repoRoot, baseRef, 'package.json').version,
79
+ currentVersion: currentPackage.version,
80
+ baseManifest: gitShowJson(repoRoot, baseRef, 'agent-workflow-kit.package.json'),
81
+ checkedManifest: JSON.parse(await readFile(join(repoRoot, 'agent-workflow-kit.package.json'), 'utf8')),
82
+ builtManifest: JSON.parse(await readFile(join(distDir, 'agent-workflow-kit.package.json'), 'utf8')),
83
+ });
84
+ } finally { await rm(distDir, { recursive: true, force: true }); }
85
+ }
86
+
87
+ if (import.meta.url === `file://${process.argv[1]}`) {
88
+ const baseIndex = process.argv.indexOf('--base');
89
+ checkReleaseDelta({ baseRef: baseIndex < 0 ? undefined : process.argv[baseIndex + 1] })
90
+ .then((result) => {
91
+ if (result.ok) return console.log(`release:guard — OK${result.recommendedBump ? ` (${result.recommendedBump})` : ''}`);
92
+ console.error('release:guard — BLOCKED');
93
+ for (const error of result.errors) console.error(` ${error}`);
94
+ process.exitCode = 1;
95
+ }).catch((error) => { console.error(error.message); process.exitCode = 1; });
96
+ }
@@ -0,0 +1,53 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { assessRelease } from './release-delta-guard.mjs';
4
+
5
+ const file = (path, sha256) => ({ path, sha256 });
6
+
7
+ test('an unbumped shipped change is blocked with its concrete delta', () => {
8
+ const result = assessRelease({
9
+ baseVersion: '1.2.3', currentVersion: '1.2.3',
10
+ baseManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
11
+ builtManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'new')] },
12
+ checkedManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
13
+ });
14
+ assert.equal(result.ok, false);
15
+ assert.deepEqual(result.delta.changed, ['skill.md']);
16
+ assert.match(result.errors.join('\n'), /skill\.md/);
17
+ assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
18
+ });
19
+
20
+ test('dead checked-manifest entries are rejected', () => {
21
+ const result = assessRelease({
22
+ baseVersion: '1.2.3', currentVersion: '1.3.0',
23
+ baseManifest: { kitVersion: '1.2.3', files: [] },
24
+ builtManifest: { kitVersion: '1.3.0', files: [] },
25
+ checkedManifest: { kitVersion: '1.3.0', files: [file('gone.md', 'old')] },
26
+ });
27
+ assert.equal(result.ok, false);
28
+ assert.match(result.errors.join('\n'), /dead entry: gone\.md/);
29
+ });
30
+
31
+ test('a matching regenerated manifest and semantic bump pass', () => {
32
+ const current = { kitVersion: '1.3.0', files: [file('new.md', 'one')] };
33
+ const result = assessRelease({
34
+ baseVersion: '1.2.3', currentVersion: '1.3.0',
35
+ baseManifest: { kitVersion: '1.2.3', files: [] }, builtManifest: current,
36
+ checkedManifest: current,
37
+ });
38
+ assert.equal(result.ok, true);
39
+ assert.equal(result.recommendedBump, 'minor');
40
+ });
41
+
42
+ test('shipped content rejects downgrade and malformed version transitions', () => {
43
+ for (const currentVersion of ['1.1.0', 'banana']) {
44
+ const current = { kitVersion: currentVersion, files: [file('skill.md', 'new')] };
45
+ const result = assessRelease({
46
+ baseVersion: '1.2.3', currentVersion,
47
+ baseManifest: { kitVersion: '1.2.3', files: [file('skill.md', 'old')] },
48
+ builtManifest: current, checkedManifest: current,
49
+ });
50
+ assert.equal(result.ok, false, currentVersion);
51
+ assert.match(result.errors.join('\n'), /invalid version transition: 1\.2\.3 ->/);
52
+ }
53
+ });
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Regression test for marker-aware board issue creation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+
14
+ PROFILE = {
15
+ "repo": "example/kit",
16
+ "project": {"number": 1, "owner": "example", "nodeId": "PVT_test"},
17
+ "fields": {
18
+ "status": {"id": "STATUS", "options": {}},
19
+ "wave": "WAVE",
20
+ "cluster": "CLUSTER",
21
+ "specPath": "SPEC",
22
+ "planPath": "PLAN",
23
+ },
24
+ "labels": {
25
+ "readyForAgent": "ready-for-agent",
26
+ "typePrefix": "type:",
27
+ "clusterType": "type:cluster",
28
+ "waveStub": "wave-stub",
29
+ },
30
+ "branchPrefixes": ["feat", "fix"],
31
+ "prMarkers": {"partOf": "Part of", "retroMarker": "Retro", "retroValues": []},
32
+ "headings": {"vorBau": "Clarify Before Build"},
33
+ }
34
+
35
+
36
+ def load_board_sync():
37
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
38
+ profile.write("<!-- board-sync:profile -->\n```json\n")
39
+ json.dump(PROFILE, profile)
40
+ profile.write("\n```\n")
41
+ profile_path = profile.name
42
+ os.environ["BOARD_SYNC_PROFILE"] = profile_path
43
+ spec = importlib.util.spec_from_file_location(
44
+ "board_sync_create_test", Path(__file__).with_name("board-sync.py")
45
+ )
46
+ module = importlib.util.module_from_spec(spec)
47
+ assert spec.loader is not None
48
+ spec.loader.exec_module(module)
49
+ Path(profile_path).unlink()
50
+ return module
51
+
52
+
53
+ bs = load_board_sync()
54
+
55
+
56
+ class FakeGh:
57
+ def __init__(self, marker: str):
58
+ self.marker = marker
59
+ self.created = False
60
+ self.calls: list[list[str]] = []
61
+
62
+ def __call__(self, args: list[str]) -> str:
63
+ self.calls.append(args)
64
+ joined = " ".join(args)
65
+ if "issue list" in joined:
66
+ if not self.created:
67
+ return "[]"
68
+ return json.dumps([
69
+ {
70
+ "number": 101,
71
+ "url": "https://github.com/example/kit/issues/101",
72
+ "body": self.marker,
73
+ }
74
+ ])
75
+ if "issue create" in joined:
76
+ self.created = True
77
+ return "https://github.com/example/kit/issues/101\n"
78
+ if "project item-add" in joined:
79
+ return '{"id":"PVTI_test"}'
80
+ return ""
81
+
82
+
83
+ class MarkerAwareCreateTest(unittest.TestCase):
84
+ def test_identical_program_leaf_retry_reuses_existing_issue(self):
85
+ marker = "<!-- program-leaf-source: program-x/1a -->"
86
+ fake = FakeGh(marker)
87
+ original = bs._gh
88
+ bs._gh = fake
89
+ try:
90
+ with tempfile.NamedTemporaryFile("w", suffix=".md") as body:
91
+ body.write(marker + "\nOutcome: test\n")
92
+ body.flush()
93
+ argv = ["create", "--title", "Slice 1a", "--body-file", body.name]
94
+ self.assertEqual(bs.main(argv), 0)
95
+ self.assertEqual(bs.main(argv), 0)
96
+ finally:
97
+ bs._gh = original
98
+
99
+ creates = [call for call in fake.calls if "issue create" in " ".join(call)]
100
+ self.assertEqual(len(creates), 1)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()