@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.
- package/.agents/skills/ask-matt/SKILL.md +139 -0
- package/.agents/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/audit-skills/SKILL.md +145 -0
- package/.agents/skills/board-to-waves/SKILL.md +135 -0
- package/.agents/skills/code-review/SKILL.md +64 -0
- package/.agents/skills/codebase-design/DEEPENING.md +37 -0
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.agents/skills/codebase-design/SKILL.md +114 -0
- package/.agents/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/codex-adapter-sync/SKILL.md +151 -0
- package/.agents/skills/decision-gate/SKILL.md +67 -0
- package/.agents/skills/diagnose/SKILL.md +136 -0
- package/.agents/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +76 -0
- package/.agents/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/git-worktree-recover/SKILL.md +176 -0
- package/.agents/skills/grill-me/SKILL.md +54 -0
- package/.agents/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/grill-with-docs/SKILL.md +137 -0
- package/.agents/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/implement/SKILL.md +15 -0
- package/.agents/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.agents/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.agents/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.agents/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.agents/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/kit-release/SKILL.md +56 -0
- package/.agents/skills/local-ci/SKILL.md +94 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +261 -0
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.agents/skills/prototype/LOGIC.md +79 -0
- package/.agents/skills/prototype/SKILL.md +30 -0
- package/.agents/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.agents/skills/research/SKILL.md +12 -0
- package/.agents/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.agents/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/retro/SKILL.md +209 -0
- package/.agents/skills/scale-check/SKILL.md +141 -0
- package/.agents/skills/security-audit/SKILL.md +67 -0
- package/.agents/skills/setup-workflow/SKILL.md +187 -0
- package/.agents/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/setup-workflow/board-sync.md +111 -0
- package/.agents/skills/setup-workflow/code-review.md +26 -0
- package/.agents/skills/setup-workflow/domain.md +51 -0
- package/.agents/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.agents/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.agents/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.agents/skills/setup-workflow/triage-labels.md +18 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +33 -0
- package/.agents/skills/spec-self-critique/SKILL.md +152 -0
- package/.agents/skills/spec-self-critique/scenarios.md +82 -0
- package/.agents/skills/tdd/SKILL.md +120 -0
- package/.agents/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/tdd/deep-modules.md +33 -0
- package/.agents/skills/tdd/interface-design.md +31 -0
- package/.agents/skills/tdd/mocking.md +59 -0
- package/.agents/skills/tdd/refactoring.md +10 -0
- package/.agents/skills/tdd/tests.md +61 -0
- package/.agents/skills/to-issues/SKILL.md +325 -0
- package/.agents/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.agents/skills/to-prd/SKILL.md +180 -0
- package/.agents/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-waves/SKILL.md +260 -0
- package/.agents/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.agents/skills/triage/AGENT-BRIEF.md +168 -0
- package/.agents/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.agents/skills/triage/SKILL.md +122 -0
- package/.agents/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/verify-spike/SKILL.md +39 -0
- package/.agents/skills/wayfinder/SKILL.md +127 -0
- package/.agents/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/wrapup/SKILL.md +71 -0
- package/.agents/skills/wrapup/SOURCES.txt +6 -0
- package/.agents/skills/zoom-out/SKILL.md +7 -0
- package/.agents/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/hooks/_hook_utils.py +184 -0
- package/.claude/hooks/drift-guard.py +176 -0
- package/.claude/hooks/skill-drift-hint.py +122 -0
- package/.claude/hooks/sync-board-status.py +191 -0
- package/.claude/skills/ask-matt/SKILL.md +139 -0
- package/.claude/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/audit-skills/SKILL.md +145 -0
- package/.claude/skills/board-to-waves/SKILL.md +148 -0
- package/.claude/skills/code-review/SKILL.md +64 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/codex-build/SKILL.md +126 -0
- package/.claude/skills/codex-build/THIRD-PARTY-NOTICES.md +35 -0
- package/.claude/skills/codex-review/SKILL.md +145 -0
- package/.claude/skills/codex-review/THIRD-PARTY-NOTICES.md +31 -0
- package/.claude/skills/decision-gate/SKILL.md +67 -0
- package/.claude/skills/diagnose/SKILL.md +136 -0
- package/.claude/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.claude/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.claude/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/domain-modeling/SKILL.md +76 -0
- package/.claude/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/SKILL.md +96 -0
- package/.claude/skills/git-guardrails-claude-code/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +28 -0
- package/.claude/skills/git-worktree-recover/SKILL.md +182 -0
- package/.claude/skills/grill-me/SKILL.md +54 -0
- package/.claude/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-me-codex/SKILL.md +158 -0
- package/.claude/skills/grill-me-codex/THIRD-PARTY-NOTICES.md +32 -0
- package/.claude/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs/SKILL.md +137 -0
- package/.claude/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-with-docs-codex/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs-codex/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs-codex/SKILL.md +233 -0
- package/.claude/skills/grill-with-docs-codex/THIRD-PARTY-NOTICES.md +33 -0
- package/.claude/skills/implement/SKILL.md +15 -0
- package/.claude/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.claude/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.claude/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.claude/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/kit-release/SKILL.md +56 -0
- package/.claude/skills/local-ci/SKILL.md +94 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +269 -0
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.claude/skills/prototype/LOGIC.md +79 -0
- package/.claude/skills/prototype/SKILL.md +30 -0
- package/.claude/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/prototype/UI.md +112 -0
- package/.claude/skills/research/SKILL.md +12 -0
- package/.claude/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.claude/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/retro/SKILL.md +209 -0
- package/.claude/skills/scale-check/SKILL.md +141 -0
- package/.claude/skills/security-audit/SKILL.md +67 -0
- package/.claude/skills/setup-pre-commit/SKILL.md +104 -0
- package/.claude/skills/setup-pre-commit/scripts/pre-commit.template.sh +35 -0
- package/.claude/skills/setup-workflow/SKILL.md +187 -0
- package/.claude/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/setup-workflow/board-sync.md +111 -0
- package/.claude/skills/setup-workflow/code-review.md +26 -0
- package/.claude/skills/setup-workflow/domain.md +51 -0
- package/.claude/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.claude/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.claude/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.claude/skills/setup-workflow/triage-labels.md +18 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +33 -0
- package/.claude/skills/skill-manifest.json +389 -0
- package/.claude/skills/spec-self-critique/SKILL.md +152 -0
- package/.claude/skills/spec-self-critique/scenarios.md +82 -0
- package/.claude/skills/tdd/SKILL.md +120 -0
- package/.claude/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/tdd/deep-modules.md +33 -0
- package/.claude/skills/tdd/interface-design.md +31 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/refactoring.md +10 -0
- package/.claude/skills/tdd/tests.md +61 -0
- package/.claude/skills/to-issues/SKILL.md +325 -0
- package/.claude/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.claude/skills/to-prd/SKILL.md +180 -0
- package/.claude/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-waves/SKILL.md +260 -0
- package/.claude/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.claude/skills/triage/AGENT-BRIEF.md +168 -0
- package/.claude/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.claude/skills/triage/SKILL.md +122 -0
- package/.claude/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/verify-spike/SKILL.md +39 -0
- package/.claude/skills/wayfinder/SKILL.md +127 -0
- package/.claude/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/wrapup/SKILL.md +81 -0
- package/.claude/skills/wrapup/SOURCES.txt +6 -0
- package/.claude/skills/write-a-skill/GLOSSARY.md +201 -0
- package/.claude/skills/write-a-skill/SKILL.md +88 -0
- package/.claude/skills/write-a-skill/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/zoom-out/SKILL.md +7 -0
- package/.claude/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/LICENSE +28 -0
- package/PROVENANCE.md +39 -0
- package/README.md +619 -0
- package/agent-workflow-kit.package.json +1939 -0
- package/docs/agents/board-sync.md +71 -0
- package/docs/agents/code-review.md +14 -0
- package/docs/agents/domain.md +7 -0
- package/docs/agents/issue-tracker.md +12 -0
- package/docs/agents/security-audit-runbook-template.md +205 -0
- package/docs/agents/skills/orchestrate-wave.md +13 -0
- package/docs/agents/skills/spec-self-critique.md +18 -0
- package/docs/agents/triage-labels.md +10 -0
- package/docs/agents/wave-anchor-template.md +88 -0
- package/docs/conventions/spec-completeness.md +23 -0
- package/docs/index.html +56 -0
- package/docs/methodology.html +488 -0
- package/docs/methodology.svg +93 -0
- package/docs/workflow.png +0 -0
- package/package.json +40 -0
- package/scripts/anchor_table.py +343 -0
- package/scripts/board-sync.py +1232 -0
- package/scripts/board_config.py +147 -0
- package/scripts/board_fields.py +192 -0
- package/scripts/build-kit.mjs +116 -0
- package/scripts/build-kit.test.mjs +59 -0
- package/scripts/check-kit-staleness.mjs +45 -0
- package/scripts/check-kit-staleness.test.mjs +19 -0
- package/scripts/execute-ready-check.py +516 -0
- package/scripts/issue_deps.py +79 -0
- package/scripts/kit-release.mjs +100 -0
- package/scripts/kit-release.test.mjs +67 -0
- package/scripts/lib/audit-refs.mjs +146 -0
- package/scripts/lib/scrub.mjs +113 -0
- package/scripts/lib/scrub.test.mjs +187 -0
- package/scripts/loc_offender_core.py +196 -0
- package/scripts/loc_offender_gate.py +182 -0
- package/scripts/node_kind.py +68 -0
- package/scripts/portability_profile_scan.py +53 -0
- package/scripts/pr-body-check.py +263 -0
- package/scripts/program_graph.py +125 -0
- package/scripts/program_graph_parse.py +275 -0
- package/scripts/program_graph_validate.py +256 -0
- package/scripts/program_sync.py +160 -0
- package/scripts/release-delta-guard.mjs +96 -0
- package/scripts/release-delta-guard.test.mjs +53 -0
- package/scripts/test_board_sync_create_idempotency.py +104 -0
- package/scripts/test_dist_kit_smoke.py +199 -0
- package/scripts/test_kit_docs_language_census.py +178 -0
- package/scripts/test_skill_code_review_seed.py +89 -0
- package/scripts/test_skill_frontmatter_lint.py +226 -0
- package/scripts/test_skill_gh_lint.py +124 -0
- package/scripts/test_skill_language_census.py +254 -0
- package/scripts/test_skill_portability_lint.py +773 -0
- package/scripts/test_skill_publish_audit.py +300 -0
- package/scripts/test_skill_selfcontainment_lint.py +255 -0
- package/scripts/test_skill_setup_workflow_seeds.py +200 -0
- package/scripts/test_skill_stale_name_lint.py +126 -0
- package/scripts/test_skill_surface_refs.py +300 -0
- package/scripts/test_skill_trailing_artifact_lint.py +71 -0
- package/scripts/wrapup-land.py +573 -0
- package/src/cli.mjs +70 -0
- package/src/commands/diff.mjs +6 -0
- package/src/commands/init.mjs +59 -0
- package/src/commands/uninstall.mjs +43 -0
- package/src/commands/update.mjs +95 -0
- package/src/lib/atomicWrite.mjs +49 -0
- package/src/lib/bundle.mjs +133 -0
- package/src/lib/hash.mjs +13 -0
- package/src/lib/manifest.mjs +58 -0
- package/src/lib/sentinel.mjs +20 -0
- 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())
|