@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,71 @@
1
+ #!/usr/bin/env python3
2
+ """Trailing paste-artifact lint (#1991 retro).
3
+
4
+ Skill bodies and agent docs are often assembled by pasting rendered tool output.
5
+ That paste can drag a literal harness closing tag along — a stray `</content>`
6
+ or `</invoke>` line at the end of the file. It is invisible in most renders but
7
+ ships verbatim (one such line sat in a publish:true skill and would have shipped
8
+ in the kit). The 2026-07-09 census found 8 of them across both skill surfaces
9
+ and docs/agents.
10
+
11
+ This lint fails when the LAST non-empty line of a scanned file is a known paste
12
+ artifact. Deliberately trailing-only: the same token inside a fenced code block
13
+ mid-file can be a legitimate example, but as the file's final line it is always
14
+ garbage. Extend ARTIFACTS only with verified occurrences, not speculation.
15
+
16
+ Run: python3 scripts/test_skill_trailing_artifact_lint.py
17
+ """
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ ROOT = Path(__file__).resolve().parent.parent
22
+
23
+ SCAN_DIRS = [".claude/skills", ".agents/skills", "docs/agents"]
24
+
25
+ # Verified paste artifacts (both seen in the 2026-07-09 census). Extend on sight.
26
+ ARTIFACTS = {"</content>", "</invoke>"}
27
+
28
+
29
+ def _last_nonempty_line(path):
30
+ last = None
31
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
32
+ if line.strip():
33
+ last = line.strip()
34
+ return last
35
+
36
+
37
+ def _scan():
38
+ hits = []
39
+ for d in SCAN_DIRS:
40
+ base = ROOT / d
41
+ if not base.is_dir():
42
+ continue
43
+ for p in sorted(base.rglob("*.md")):
44
+ if _last_nonempty_line(p) in ARTIFACTS:
45
+ hits.append(str(p.relative_to(ROOT)))
46
+ return hits
47
+
48
+
49
+ class TrailingPasteArtifact(unittest.TestCase):
50
+ def test_no_file_ends_on_a_paste_artifact(self):
51
+ hits = _scan()
52
+ self.assertEqual(
53
+ hits, [],
54
+ "file(s) end on a literal harness closing tag — a paste artifact "
55
+ f"from assembling the body out of rendered tool output; delete the "
56
+ f"trailing line(s): {hits}",
57
+ )
58
+
59
+ def test_detector_fires_on_synthetic_artifact(self):
60
+ """Negative proof: the detector actually recognizes an artifact tail."""
61
+ import tempfile
62
+
63
+ with tempfile.TemporaryDirectory() as td:
64
+ p = Path(td) / "x.md"
65
+ p.write_text("# Title\n\nbody\n</content>\n\n")
66
+ self.assertEqual(_last_nonempty_line(p), "</content>")
67
+ self.assertIn(_last_nonempty_line(p), ARTIFACTS)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main(verbosity=2)
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """wrapup-land.py — mechanical executor for the /wrapup skill.
3
+
4
+ Replaces the former Sonnet phase-2 subagent: every enumerable
5
+ git/gh step of landing a slice runs here deterministically; judgment
6
+ (secret review, commit message, PR body text, drift-fallback candidates,
7
+ sibling propagation) stays with the calling agent.
8
+
9
+ Subcommands
10
+ preflight read-only context report (run in the feature worktree)
11
+ commit .env hard block + secret grep + git commit (run in the worktree)
12
+ land push → PR → body-check → merge → teardown → sweeps → anchor-sync
13
+ (run FROM the main tree; refuses to run inside the worktree)
14
+
15
+ Output: one JSON report on stdout. Exit 0 = ok, 1 = STOP (reason in JSON),
16
+ 2 = usage/context error. On STOP nothing is forced — no --force, no -D,
17
+ no --no-verify; the caller diagnoses.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ # Secret pattern mirrors the historical /wrapup Step-0a grep (era).
33
+ SECRET_RE = re.compile(
34
+ r"BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|password|access[_-]?token|bearer)\s*[:=]",
35
+ re.IGNORECASE,
36
+ )
37
+ ENV_PATH_RE = re.compile(r"(^|/)\.env(\.[^/]*)?$")
38
+ ISSUE_BRANCH_RE = re.compile(r"^(feat|fix|chore|docs)/(\d+)-")
39
+ # ANNAHMEN.md drift-log line: "- #<n>: text" or "- #<n> §<section>: text"
40
+ DRIFT_LINE_RE = re.compile(r"^-\s*#(\d+)(?:\s*§([^:]+?))?\s*:\s*(.+)$")
41
+ RETRO_LINE_RE = re.compile(r"^\*\*Retro:\*\*", re.MULTILINE)
42
+ DRIFT_MARKER_RE = re.compile(r"<!--\s*annahme-drift:\s*(\{.*?\})\s*-->")
43
+ RED_CHECK_CONCLUSIONS = {"FAILURE", "CANCELLED", "TIMED_OUT"}
44
+
45
+
46
+ class Stop(Exception):
47
+ def __init__(self, step: str, reason: str, detail: str = ""):
48
+ super().__init__(reason)
49
+ self.step, self.reason, self.detail = step, reason, detail
50
+
51
+
52
+ def run(cmd: list[str], cwd: str | None = None, check: bool = False) -> subprocess.CompletedProcess:
53
+ p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
54
+ if check and p.returncode != 0:
55
+ raise Stop(cmd[0], f"command failed: {' '.join(cmd)}",
56
+ (p.stderr or p.stdout).strip()[-2000:])
57
+ return p
58
+
59
+
60
+ def git(args: list[str], cwd: str | None = None, check: bool = False) -> subprocess.CompletedProcess:
61
+ return run(["git", *args], cwd=cwd, check=check)
62
+
63
+
64
+ # ---------- context ----------
65
+
66
+ def worktree_map(cwd: str | None = None) -> tuple[str, dict[str, str]]:
67
+ """Return (main_tree, {branch: worktree_path}) from `git worktree list`."""
68
+ out = git(["worktree", "list", "--porcelain"], cwd=cwd, check=True).stdout
69
+ main_tree, mapping, wt = "", {}, ""
70
+ for line in out.splitlines():
71
+ if line.startswith("worktree "):
72
+ wt = line.split(" ", 1)[1]
73
+ if not main_tree:
74
+ main_tree = wt
75
+ elif line.startswith("branch refs/heads/"):
76
+ mapping[line.split("refs/heads/", 1)[1]] = wt
77
+ return main_tree, mapping
78
+
79
+
80
+ def issue_from_branch(branch: str) -> str | None:
81
+ m = ISSUE_BRANCH_RE.match(branch)
82
+ return m.group(2) if m else None
83
+
84
+
85
+ def load_profile() -> dict:
86
+ try:
87
+ from board_config import load_board_config
88
+ return load_board_config()
89
+ except Exception:
90
+ return {}
91
+
92
+
93
+ # ---------- drift log (ANNAHMEN.md) ----------
94
+
95
+ def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
96
+ """Split drift-log lines into well-formed marker dicts and malformed leftovers."""
97
+ wellformed, malformed = [], []
98
+ for raw in text.splitlines():
99
+ line = raw.strip()
100
+ if not line or not line.startswith("-"):
101
+ continue
102
+ m = DRIFT_LINE_RE.match(line)
103
+ if m:
104
+ wellformed.append({
105
+ "target": f"#{m.group(1)}",
106
+ "section": (m.group(2) or default_section).strip(),
107
+ "op": "append",
108
+ "text": m.group(3).strip(),
109
+ })
110
+ else:
111
+ malformed.append(line)
112
+ return wellformed, malformed
113
+
114
+
115
+ def marker_comment(marker: dict) -> str:
116
+ payload = json.dumps(marker, ensure_ascii=False)
117
+ return f"<!-- annahme-drift: {payload} -->"
118
+
119
+
120
+ def merge_markers_into_body(body: str, markers: list[dict]) -> str:
121
+ """Append marker comments not already present (dedupe on target+text)."""
122
+ have = set()
123
+ for m in DRIFT_MARKER_RE.finditer(body):
124
+ try:
125
+ d = json.loads(m.group(1))
126
+ have.add((d.get("target"), d.get("text")))
127
+ except json.JSONDecodeError:
128
+ continue
129
+ add = [marker_comment(m) for m in markers if (m["target"], m["text"]) not in have]
130
+ if not add:
131
+ return body
132
+ return body.rstrip("\n") + "\n\n" + "\n".join(add) + "\n"
133
+
134
+
135
+ # ---------- secret / .env checks ----------
136
+
137
+ def env_paths(porcelain: str) -> list[str]:
138
+ hits = []
139
+ for line in porcelain.splitlines():
140
+ path = line[3:].split(" -> ")[-1].strip().strip('"')
141
+ if ENV_PATH_RE.search(path):
142
+ hits.append(path)
143
+ return hits
144
+
145
+
146
+ def secret_hits_in(diff_text: str) -> list[str]:
147
+ hits = []
148
+ for n, line in enumerate(diff_text.splitlines(), 1):
149
+ if line.startswith("+") and not line.startswith("+++") and SECRET_RE.search(line):
150
+ hits.append(f"{n}: {line[:200]}")
151
+ return hits
152
+
153
+
154
+ # ---------- dev-server kill ----------
155
+
156
+ def parse_dev_ports(text: str) -> list[str]:
157
+ ports = []
158
+ for line in text.splitlines():
159
+ m = re.match(r"^(VITE_DEV_PORT|BACKEND_PORT)=(\d+)\s*$", line.strip())
160
+ if m:
161
+ ports.append(m.group(2))
162
+ return ports
163
+
164
+
165
+ def self_ancestry() -> set[int]:
166
+ pids, pid = set(), os.getpid()
167
+ while pid > 1:
168
+ pids.add(pid)
169
+ try:
170
+ with open(f"/proc/{pid}/status") as fh:
171
+ pid = next(int(l.split()[1]) for l in fh if l.startswith("PPid:"))
172
+ except (OSError, StopIteration):
173
+ break
174
+ return pids
175
+
176
+
177
+ def kill_worktree_processes(wt: str) -> list[str]:
178
+ """Kill port listeners (.dev-ports) + processes with cwd under the worktree.
179
+
180
+ Own shell ancestry is excluded (the self-kill trap, mechanically)."""
181
+ killed, protected = [], self_ancestry()
182
+ ports = []
183
+ dev_ports = Path(wt) / ".dev-ports"
184
+ if dev_ports.is_file():
185
+ ports = parse_dev_ports(dev_ports.read_text())
186
+ for port in ports:
187
+ p = run(["lsof", f"-ti:{port}"])
188
+ for pid in p.stdout.split():
189
+ if pid.isdigit() and int(pid) not in protected:
190
+ run(["kill", "-9", pid])
191
+ killed.append(f"port {port} pid {pid}")
192
+ p = run(["pgrep", "-f", "tsx|vite|tsc|pnpm|node"])
193
+ wt_real = os.path.realpath(wt)
194
+ for pid in p.stdout.split():
195
+ if not pid.isdigit() or int(pid) in protected:
196
+ continue
197
+ try:
198
+ cwd = os.path.realpath(f"/proc/{pid}/cwd")
199
+ except OSError:
200
+ continue
201
+ if cwd == wt_real or cwd.startswith(wt_real + os.sep):
202
+ run(["kill", pid])
203
+ killed.append(f"cwd pid {pid}")
204
+ return killed
205
+
206
+
207
+ # ---------- anchor completeness ----------
208
+
209
+ def anchor_complete_from_body(body: str) -> bool | None:
210
+ """True when every slice-table row's Status cell is ✅; None when no table."""
211
+ try:
212
+ import anchor_table as at
213
+ headers, rows = at.current_slice_table(body)
214
+ idx = at.require_col_index(headers, "Status")
215
+ except Exception:
216
+ return None
217
+ if not rows:
218
+ return None
219
+ return all(at.status_base(r[idx]).startswith("✅") for r in rows if len(r) > idx)
220
+
221
+
222
+ # ---------- remote sweep set ----------
223
+
224
+ def stale_remote_set(merged: set[str], remotes: set[str], open_prs: set[str]) -> list[str]:
225
+ return sorted((merged & remotes) - open_prs - {"main"})
226
+
227
+
228
+ # ---------- subcommands ----------
229
+
230
+ def cmd_preflight(args) -> dict:
231
+ cwd = os.getcwd()
232
+ main_tree, _ = worktree_map(cwd)
233
+ branch = git(["branch", "--show-current"], check=True).stdout.strip()
234
+ wt = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
235
+ if os.path.realpath(wt) == os.path.realpath(main_tree) or branch == "main":
236
+ raise Stop("preflight", "not in a feature worktree",
237
+ f"wt={wt} branch={branch} — /wrapup runs in the finished slice's worktree")
238
+
239
+ porcelain = git(["status", "--porcelain"], check=True).stdout
240
+ profile = load_profile()
241
+ default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
242
+
243
+ annahmen_path = Path(wt) / "ANNAHMEN.md"
244
+ wellformed, malformed = ([], [])
245
+ if annahmen_path.is_file():
246
+ wellformed, malformed = parse_annahmen(annahmen_path.read_text(), default_section)
247
+
248
+ diff_text = git(["diff", "HEAD"]).stdout
249
+ untracked = [l[3:].strip() for l in porcelain.splitlines() if l.startswith("??")]
250
+ hits = secret_hits_in(diff_text)
251
+ for u in untracked:
252
+ p = Path(wt) / u
253
+ if p.is_file() and p.stat().st_size < 512_000:
254
+ try:
255
+ content = p.read_text(errors="ignore")
256
+ except OSError:
257
+ continue
258
+ hits += [f"{u}:{h}" for h in secret_hits_in(
259
+ "\n".join("+" + l for l in content.splitlines()))]
260
+
261
+ issue = issue_from_branch(branch)
262
+ parent = None
263
+ if issue:
264
+ p = run([sys.executable, str(Path(__file__).parent / "board-sync.py"), "parent-of", issue])
265
+ parent = p.stdout.strip() if p.returncode == 0 and p.stdout.strip() else "unknown"
266
+
267
+ pr = {}
268
+ p = run(["gh", "pr", "view", branch, "--json", "number,state,title,body"])
269
+ if p.returncode == 0:
270
+ d = json.loads(p.stdout)
271
+ pr = {"number": d["number"], "state": d["state"], "title": d["title"],
272
+ "has_retro_line": bool(RETRO_LINE_RE.search(d.get("body") or "")),
273
+ "body": d.get("body") or ""}
274
+
275
+ return {
276
+ "wt": wt, "branch": branch, "main_tree": main_tree,
277
+ "issue": issue, "parent": parent,
278
+ "dirty": bool(porcelain.strip()), "dirty_files": porcelain.splitlines(),
279
+ "env_files": env_paths(porcelain),
280
+ "secret_hits": hits,
281
+ "drift": {"wellformed": wellformed, "malformed": malformed,
282
+ "log_present": annahmen_path.is_file()},
283
+ "existing_pr": pr or None,
284
+ "profile": {"retro_values": profile.get("prMarkers", {}).get("retroValues", []),
285
+ "vor_bau": default_section,
286
+ "remote_sweep": bool(profile.get("wrapup", {}).get("remoteBranchSweep"))},
287
+ }
288
+
289
+
290
+ def cmd_commit(args) -> dict:
291
+ branch = git(["branch", "--show-current"], check=True).stdout.strip()
292
+ if branch == "main":
293
+ raise Stop("commit", "on main — refusing to commit")
294
+ porcelain = git(["status", "--porcelain"], check=True).stdout
295
+ if not porcelain.strip():
296
+ return {"committed": False, "reason": "clean tree — nothing to commit"}
297
+ envs = env_paths(porcelain)
298
+ if envs:
299
+ raise Stop("commit", ".env in the working tree — never commit", "\n".join(envs))
300
+ git(["add", "-A"], check=True)
301
+ hits = secret_hits_in(git(["diff", "--cached"], check=True).stdout)
302
+ if hits and not args.allow_matches:
303
+ git(["reset"])
304
+ raise Stop("commit", "possible secrets in the staged diff — review; "
305
+ "false positive → re-run with --allow-matches, real secret → resolve first",
306
+ "\n".join(hits[:40]))
307
+ p = git(["commit", "-m", args.message])
308
+ if p.returncode != 0:
309
+ # pre-commit hook (tsc/ESLint via core.hooksPath) failed — caller diagnoses
310
+ # (many unrelated TS2307 in a node repo usually = stale worktree node_modules
311
+ # → pnpm install --frozen-lockfile, then re-run; never --no-verify).
312
+ raise Stop("commit", "git commit failed (pre-commit hook?)",
313
+ (p.stderr + "\n" + p.stdout).strip()[-3000:])
314
+ sha = git(["rev-parse", "HEAD"], check=True).stdout.strip()
315
+ return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
316
+
317
+
318
+ def cmd_land(args) -> dict:
319
+ report: dict = {"stops": [], "warnings": []}
320
+ main_tree, branches = worktree_map()
321
+ here = os.path.realpath(os.getcwd())
322
+ if here != os.path.realpath(main_tree):
323
+ raise Stop("land", "run `land` from the main tree",
324
+ f"cwd={here} main={main_tree} — the in-worktree shell would survive "
325
+ "teardown and the process-kill step")
326
+ branch = args.branch
327
+ wt = branches.get(branch)
328
+ wt_exists = wt is not None and Path(wt).is_dir()
329
+ profile = load_profile()
330
+ default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
331
+
332
+ # drift markers from the build-time log — mechanical, no gate
333
+ markers: list[dict] = []
334
+ if wt_exists:
335
+ if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
336
+ raise Stop("land", "worktree dirty — run `commit` first", wt)
337
+ annahmen = Path(wt) / "ANNAHMEN.md"
338
+ if annahmen.is_file():
339
+ markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
340
+ if malformed and not args.skip_malformed_drift:
341
+ raise Stop("land", "malformed ANNAHMEN.md line(s) — no #<n> target; "
342
+ "fix the log or re-run with --skip-malformed-drift",
343
+ "\n".join(malformed))
344
+ report["drift_markers"] = markers
345
+
346
+ # Step 0b — push
347
+ if wt_exists:
348
+ p = git(["push", "-u", "origin", branch], cwd=wt)
349
+ if p.returncode != 0:
350
+ raise Stop("0b push", "push rejected", (p.stderr or p.stdout).strip()[-2000:])
351
+
352
+ # Step 0c — ensure PR + final body
353
+ p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
354
+ pr_body = ""
355
+ if p.returncode == 0:
356
+ d = json.loads(p.stdout)
357
+ pr, pr_state, pr_body = str(d["number"]), d["state"], d.get("body") or ""
358
+ report["pr_reused"] = True
359
+ elif args.body_file:
360
+ if not args.title:
361
+ raise Stop("0c pr", "--title required to create a PR")
362
+ run(["gh", "pr", "create", "--base", "main", "--head", branch,
363
+ "--title", args.title, "--body-file", args.body_file], check=True)
364
+ d = json.loads(run(["gh", "pr", "view", branch, "--json", "number,state,body"],
365
+ check=True).stdout)
366
+ pr, pr_state, pr_body = str(d["number"]), d["state"], d.get("body") or ""
367
+ report["pr_reused"] = False
368
+ else:
369
+ raise Stop("0c pr", "no open PR and no --body-file to create one")
370
+ report["pr"] = pr
371
+
372
+ if args.body_file and report["pr_reused"]:
373
+ pr_body = Path(args.body_file).read_text()
374
+ final = merge_markers_into_body(pr_body, markers)
375
+ tmp = Path(args.body_file).with_suffix(".final.md")
376
+ tmp.write_text(final)
377
+ run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
378
+ elif markers:
379
+ final = merge_markers_into_body(pr_body, markers)
380
+ if final != pr_body:
381
+ tmp = Path(f"/tmp/wrapup-body-{pr}.md")
382
+ tmp.write_text(final)
383
+ run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
384
+
385
+ # body-convention check: closes-vs-Part-of + **Retro:** line
386
+ p = run([sys.executable, str(Path(__file__).parent / "pr-body-check.py"),
387
+ "--branch", branch])
388
+ report["body_check_exit"] = p.returncode
389
+ if p.returncode == 1:
390
+ raise Stop("0c body-check", "pr-body-check exit 1 — fix the body, never merge red",
391
+ (p.stdout + p.stderr).strip()[-2000:])
392
+ if p.returncode == 2:
393
+ report["warnings"].append("pr-body-check exit 2 (fail-open): "
394
+ + (p.stdout + p.stderr).strip()[:300])
395
+
396
+ # merge gate
397
+ d = json.loads(run(["gh", "pr", "view", pr, "--json",
398
+ "state,mergeable,mergeStateStatus,statusCheckRollup"],
399
+ check=True).stdout)
400
+ red = [c for c in (d.get("statusCheckRollup") or [])
401
+ if (c.get("conclusion") or "").upper() in RED_CHECK_CONCLUSIONS]
402
+ if red:
403
+ raise Stop("0c merge-gate", "red checks on the PR",
404
+ ", ".join(c.get("name") or c.get("context", "?") for c in red))
405
+ if d.get("mergeable") == "CONFLICTING":
406
+ raise Stop("0c merge-gate", "PR is CONFLICTING — rebase/resolve the branch")
407
+ already_merged = d.get("state") == "MERGED"
408
+ if d.get("state") not in ("OPEN", "MERGED"):
409
+ raise Stop("0c merge-gate", f"PR state {d.get('state')} — cannot merge")
410
+
411
+ # Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
412
+ # PR state is the authority, not gh's exit code: `--delete-branch` also tries
413
+ # to delete the LOCAL branch, which fails while a worktree holds it — the
414
+ # remote merge is through anyway (dogfood run PR).
415
+ merge_err = ""
416
+ if not already_merged:
417
+ p = run(["gh", "pr", "merge", pr, "--merge", "--delete-branch"], cwd=main_tree)
418
+ if p.returncode != 0:
419
+ merge_err = (p.stderr or p.stdout).strip()[-2000:]
420
+ state = json.loads(run(["gh", "pr", "view", pr, "--json", "state"],
421
+ check=True).stdout)["state"]
422
+ if state != "MERGED":
423
+ raise Stop("1 merge", f"PR state after merge is {state}, not MERGED — aborting teardown",
424
+ merge_err)
425
+ if merge_err:
426
+ report["warnings"].append(f"gh pr merge exited non-zero but PR is MERGED: {merge_err[:300]}")
427
+ report["merged"] = True
428
+
429
+ # Step 2 — kill the worktree's dev server, then Step 4 — teardown
430
+ if wt_exists:
431
+ report["killed_processes"] = kill_worktree_processes(wt)
432
+ p = git(["worktree", "remove", wt], cwd=main_tree)
433
+ if p.returncode != 0:
434
+ raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
435
+ "check for surviving processes (lsof/pgrep)",
436
+ (p.stderr or p.stdout).strip()[-1000:])
437
+ git(["worktree", "prune"], cwd=main_tree)
438
+ report["worktree_removed"] = wt
439
+ git(["fetch", "origin", "--prune"], cwd=main_tree)
440
+
441
+ # Step 5 — main ff + local branch delete (-d only, after the pull)
442
+ git(["checkout", "main"], cwd=main_tree)
443
+ p = git(["pull", "--ff-only"], cwd=main_tree)
444
+ if p.returncode != 0:
445
+ raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
446
+ (p.stderr or p.stdout).strip()[-1000:])
447
+ p = git(["branch", "-d", branch], cwd=main_tree)
448
+ if p.returncode != 0 and branch in worktree_map()[1]:
449
+ report["warnings"].append(f"branch -d {branch} refused (still checked out?) — never -D")
450
+ elif p.returncode != 0:
451
+ report["warnings"].append(f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}")
452
+
453
+ # Step 5b — verify issue auto-close (backtick-swallowed `closes` misses)
454
+ issue = issue_from_branch(branch)
455
+ if issue:
456
+ p = run(["gh", "issue", "view", issue, "--json", "state"])
457
+ if p.returncode == 0 and json.loads(p.stdout)["state"] == "OPEN":
458
+ run(["gh", "issue", "close", issue, "-c",
459
+ f"Merged via PR #{pr} — auto-close didn't fire; closed manually."])
460
+ report["issue_close"] = f"#{issue} closed manually"
461
+ elif p.returncode == 0:
462
+ report["issue_close"] = f"#{issue} already closed"
463
+
464
+ # Step 5c — local merged-branch sweep (-d only: unreachable-from-main is refused)
465
+ swept = []
466
+ p = git(["branch", "--merged", "main", "--format=%(refname:short)"], cwd=main_tree)
467
+ for b in p.stdout.split():
468
+ if b in ("main", branch) or not b:
469
+ continue
470
+ if git(["branch", "-d", b], cwd=main_tree).returncode == 0:
471
+ swept.append(b)
472
+ report["swept_local"] = swept
473
+
474
+ # Step 5d — remote merged-PR sweep (opt-in via wrapup.remoteBranchSweep;
475
+ # PR-status-authoritative, ls-remote not branch -r —)
476
+ merged_heads = set(run(["gh", "pr", "list", "--state", "merged", "--limit", "1000",
477
+ "--json", "headRefName", "-q", ".[].headRefName"],
478
+ check=True).stdout.split())
479
+ open_heads = set(run(["gh", "pr", "list", "--state", "open", "--limit", "1000",
480
+ "--json", "headRefName", "-q", ".[].headRefName"],
481
+ check=True).stdout.split())
482
+ remote_heads = set()
483
+ for line in git(["ls-remote", "--heads", "origin"], cwd=main_tree, check=True).stdout.splitlines():
484
+ if "refs/heads/" in line:
485
+ remote_heads.add(line.split("refs/heads/", 1)[1].strip())
486
+ stale = stale_remote_set(merged_heads, remote_heads, open_heads)
487
+ if not profile.get("wrapup", {}).get("remoteBranchSweep"):
488
+ report["swept_remote"] = {"enabled": False, "stale_count": len(stale)}
489
+ elif stale:
490
+ p = git(["push", "origin", "--delete", *stale], cwd=main_tree)
491
+ git(["fetch", "origin", "--prune"], cwd=main_tree)
492
+ report["swept_remote"] = {"enabled": True, "deleted": stale if p.returncode == 0 else [],
493
+ "error": None if p.returncode == 0 else p.stderr.strip()[-500:]}
494
+ else:
495
+ report["swept_remote"] = {"enabled": True, "deleted": []}
496
+
497
+ # Step 5e.1 — anchor tracker sync + completeness; 5e.3 land sanity
498
+ anchor = args.anchor
499
+ if not anchor and issue:
500
+ p = run([sys.executable, str(Path(__file__).parent / "board-sync.py"),
501
+ "parent-of", issue])
502
+ out = p.stdout.strip()
503
+ anchor = out if p.returncode == 0 and out.isdigit() else None
504
+ if anchor:
505
+ bs = str(Path(__file__).parent / "board-sync.py")
506
+ dry = run([sys.executable, bs, "anchor-sync", str(anchor), "--dry-run"])
507
+ wet = run([sys.executable, bs, "anchor-sync", str(anchor)])
508
+ report["anchor_sync"] = {
509
+ "anchor": anchor, "ok": wet.returncode == 0,
510
+ "diff": dry.stdout.strip()[-2000:],
511
+ "error": None if wet.returncode == 0 else (wet.stderr or wet.stdout).strip()[-1000:],
512
+ }
513
+ p = run(["gh", "issue", "view", str(anchor), "--json", "body"])
514
+ if p.returncode == 0:
515
+ report["anchor_complete"] = anchor_complete_from_body(
516
+ json.loads(p.stdout).get("body") or "")
517
+ audit = run([sys.executable, str(Path(__file__).parent / "execute-ready-check.py"),
518
+ "--issue", str(anchor), "--mode", "audit"])
519
+ report["land_sanity"] = (audit.stdout + audit.stderr).strip()[-1500:]
520
+
521
+ # Step 5e.1b — upward propagation: a wave anchor inside a program
522
+ # bubbles the slice event up — the Program-PRD's Wellenplan Status/Issue
523
+ # cells + mechanically completed Phasen-Gates refresh via program-sync.
524
+ p = run([sys.executable, bs, "parent-of", str(anchor)])
525
+ program = p.stdout.strip() if p.returncode == 0 and p.stdout.strip().isdigit() else None
526
+ if program:
527
+ pdry = run([sys.executable, bs, "program-sync", str(program), "--dry-run"])
528
+ pwet = run([sys.executable, bs, "program-sync", str(program)])
529
+ if "not a Program-PRD" in ((pwet.stderr or "") + (pwet.stdout or "")):
530
+ report["program_sync"] = {"program": program,
531
+ "skipped": "parent is not a Program-PRD"}
532
+ else:
533
+ report["program_sync"] = {
534
+ "program": program, "ok": pwet.returncode == 0,
535
+ "diff": pdry.stdout.strip()[-2000:],
536
+ "error": None if pwet.returncode == 0
537
+ else (pwet.stderr or pwet.stdout).strip()[-1000:],
538
+ }
539
+
540
+ report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
541
+ check=True).stdout.strip()
542
+ return report
543
+
544
+
545
+ def main() -> int:
546
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
547
+ sub = ap.add_subparsers(dest="cmd", required=True)
548
+ sub.add_parser("preflight", help="read-only context report (run in the worktree)")
549
+ c = sub.add_parser("commit", help="guarded commit (run in the worktree)")
550
+ c.add_argument("-m", "--message", required=True)
551
+ c.add_argument("--allow-matches", action="store_true",
552
+ help="proceed despite secret-grep matches (after human review)")
553
+ l = sub.add_parser("land", help="push→PR→merge→teardown (run from the MAIN tree)")
554
+ l.add_argument("--branch", required=True)
555
+ l.add_argument("--title", help="PR title (create path)")
556
+ l.add_argument("--body-file", help="final PR body (create or overwrite)")
557
+ l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
558
+ l.add_argument("--skip-malformed-drift", action="store_true")
559
+ args = ap.parse_args()
560
+
561
+ try:
562
+ result = {"preflight": cmd_preflight, "commit": cmd_commit, "land": cmd_land}[args.cmd](args)
563
+ print(json.dumps({"ok": True, "cmd": args.cmd, **result}, ensure_ascii=False, indent=2))
564
+ return 0
565
+ except Stop as s:
566
+ print(json.dumps({"ok": False, "cmd": args.cmd,
567
+ "stop": {"step": s.step, "reason": s.reason, "detail": s.detail}},
568
+ ensure_ascii=False, indent=2))
569
+ return 1
570
+
571
+
572
+ if __name__ == "__main__":
573
+ sys.exit(main())