@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -7,14 +7,32 @@ git/gh step of landing a slice runs here deterministically; judgment
7
7
  sibling propagation) stays with the calling agent.
8
8
 
9
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)
10
+ preflight read-only context report (run in the worktree being landed)
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
+ content-claim read-only: infer the durable content a planning session left
15
+ dirty in the main checkout, each path with its blob hash
16
+ content-commit land a confirmed claim of that content on a collision-checked
17
+ issue-less branch — explicit pathspecs only, bystanders
18
+ untouched, no teardown half (run in the main checkout)
19
+
20
+ Any born, attached worktree is first-class: a direct /wrapup invocation is the
21
+ teardown authorization, including for worktrees an external tool created under
22
+ a foreign name and path. There is no naming or location gate, no persisted
23
+ attempt state, and no recovery flag — an interrupted landing is resumed by
24
+ re-running it, because every step verifies present state and skips what is
25
+ already done.
26
+
27
+ Branch retirement is authorized, never assumed: ancestry against the freshly
28
+ fetched integration branch deletes normally, and only the platform's own PR
29
+ record — the full tuple, head SHA equal to the tip re-read immediately before
30
+ the deletion — force-deletes.
14
31
 
15
32
  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.
33
+ 2 = usage/context error. On STOP nothing is forced — no --force, no
34
+ --no-verify, and no branch deletion the authority above did not clear; the
35
+ caller diagnoses.
18
36
  """
19
37
 
20
38
  from __future__ import annotations
@@ -24,21 +42,27 @@ import importlib.util
24
42
  import json
25
43
  import os
26
44
  import re
45
+ import signal
46
+ import stat
27
47
  import subprocess
28
48
  import sys
49
+ import tempfile
29
50
  import time
30
- from pathlib import Path
51
+ from collections import Counter
52
+ from pathlib import Path, PurePosixPath
53
+ from typing import NamedTuple
31
54
  from urllib.parse import quote
32
55
 
33
56
  sys.path.insert(0, str(Path(__file__).resolve().parent))
34
57
 
58
+ from marker_lib import marker_value # noqa: E402
59
+
35
60
  # Secret pattern mirrors the historical /wrapup Step-0a grep (era).
36
61
  SECRET_RE = re.compile(
37
62
  r"BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|password|access[_-]?token|bearer)\s*[:=]",
38
63
  re.IGNORECASE,
39
64
  )
40
65
  ENV_PATH_RE = re.compile(r"(^|/)\.env(\.[^/]*)?$")
41
- ISSUE_BRANCH_RE = re.compile(r"^(feat|fix|chore|docs)/(\d+)-")
42
66
  # ANNAHMEN.md drift-log line: "- #<n>: text" or "- #<n> §<section>: text"
43
67
  DRIFT_LINE_RE = re.compile(r"^-\s*#(\d+)(?:\s*§([^:]+?))?\s*:\s*(.+)$")
44
68
  RETRO_LINE_RE = re.compile(r"^\*\*Retro:\*\*", re.MULTILINE)
@@ -71,11 +95,36 @@ INFRA_FAILURE_RE = re.compile(
71
95
  )
72
96
 
73
97
 
74
- ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
75
- RECOVER_CANONICAL_CLEANUP_FLAG = "--recover-canonical-cleanup"
76
- CANONICAL_DRIFT_RECOVERY_HINT = (
77
- f"rerun land with {RECOVER_CANONICAL_CLEANUP_FLAG} to revalidate the frozen "
78
- "landing evidence against canonical policy and resume teardown"
98
+ LIFECYCLE_PROFILE = "docs/agents/workflow-capabilities.json"
99
+ CLASSIFY_MODULE = "_wrapup_teardown_classify"
100
+ LIFECYCLE_PROFILE_MODULE = "_wrapup_lifecycle_profile"
101
+
102
+ # Census freshness is consumed read-only from the drift guard; this file owns no
103
+ # census logic of its own.
104
+ CENSUS_HOOK = ".claude/hooks/drift-guard.py"
105
+ CENSUS_STALE = "refresh_required"
106
+ CENSUS_ABSENT = "no_census"
107
+ CENSUS_UNREADABLE = "unreadable"
108
+ CENSUS_ISSUE_LIMIT = 1000
109
+ CENSUS_TRACKING_KIND = "census-refresh-source"
110
+ CENSUS_TRACKING_SLUG = "census-refresh"
111
+ CENSUS_TRACKING_TITLE = "census: the activated census needs a refresh"
112
+ CENSUS_CHECKOUT_NOTE = (
113
+ "a census describes the tree it was scanned in — a refresh committed in a "
114
+ "worktree is visible in that working tree only"
115
+ )
116
+ CENSUS_RECOVERY = (
117
+ "run `$census-update` in the evaluated checkout and land the refresh as a "
118
+ "dedicated pull request of its own — never mirror the census file from "
119
+ "another checkout"
120
+ )
121
+ CENSUS_NOT_A_GATE = (
122
+ "this finding never blocks a landing: topology drift is repo-wide and is "
123
+ "usually not caused by the pull request that just merged"
124
+ )
125
+ CENSUS_OVERRIDE_NOTE = (
126
+ "a change-local override greened the handoff gate; the verdict itself still "
127
+ "asks for a refresh"
79
128
  )
80
129
 
81
130
 
@@ -85,16 +134,18 @@ class Stop(Exception):
85
134
  self.step, self.reason, self.detail = step, reason, detail
86
135
 
87
136
 
88
- def run(cmd: list[str], cwd: str | None = None, check: bool = False) -> subprocess.CompletedProcess:
89
- p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
137
+ def run(cmd: list[str], cwd: str | None = None, check: bool = False,
138
+ env: dict | None = None) -> subprocess.CompletedProcess:
139
+ p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
90
140
  if check and p.returncode != 0:
91
141
  raise Stop(cmd[0], f"command failed: {' '.join(cmd)}",
92
142
  (p.stderr or p.stdout).strip()[-2000:])
93
143
  return p
94
144
 
95
145
 
96
- def git(args: list[str], cwd: str | None = None, check: bool = False) -> subprocess.CompletedProcess:
97
- return run(["git", *args], cwd=cwd, check=check)
146
+ def git(args: list[str], cwd: str | None = None, check: bool = False,
147
+ env: dict | None = None) -> subprocess.CompletedProcess:
148
+ return run(["git", *args], cwd=cwd, check=check, env=env)
98
149
 
99
150
 
100
151
  # ---------- PR check gate ----------
@@ -419,8 +470,20 @@ def worktree_map(cwd: str | None = None) -> tuple[str, dict[str, str]]:
419
470
 
420
471
 
421
472
  def issue_from_branch(branch: str) -> str | None:
422
- m = ISSUE_BRANCH_RE.match(branch)
423
- return m.group(2) if m else None
473
+ """Derive the issue anchor from the branch — or None, which is first-class.
474
+
475
+ Branch prefixes are a project convention read from the board profile, never
476
+ inlined. A branch that carries no issue number (an externally created
477
+ worktree, a spike) lands without an anchor instead of being refused.
478
+ """
479
+ prefixes = load_profile().get("branchPrefixes") or ()
480
+ if not prefixes:
481
+ return None
482
+ pattern = re.compile(
483
+ r"^(?:" + "|".join(re.escape(prefix) for prefix in prefixes) + r")/(\d+)-"
484
+ )
485
+ match = pattern.match(branch)
486
+ return match.group(1) if match else None
424
487
 
425
488
 
426
489
  def declared_close_targets(body: str) -> list:
@@ -443,304 +506,113 @@ def load_profile() -> dict:
443
506
  return {}
444
507
 
445
508
 
446
- def load_worktree_cleanup_core():
447
- path = Path(__file__).resolve().parent / "worktree-lifecycle" / "core.py"
448
- module_dir = str(path.parent)
449
- if module_dir not in sys.path:
450
- sys.path.insert(0, module_dir)
451
- spec = importlib.util.spec_from_file_location("_wrapup_worktree_lifecycle", path)
509
+ def load_module(name: str, path: Path, step: str):
510
+ """Load one shipped helper module exactly once per process.
511
+
512
+ Re-executing it would mint a second set of classes, so an assessment
513
+ produced by one load could not be raised or matched against the other.
514
+ """
515
+ existing = sys.modules.get(name)
516
+ if existing is not None:
517
+ return existing
518
+ spec = importlib.util.spec_from_file_location(name, path)
452
519
  if spec is None or spec.loader is None:
453
- raise Stop("cleanup", f"cannot load shared cleanup core from {path}")
520
+ raise Stop(step, f"cannot load the shipped helper module from {path}")
454
521
  module = importlib.util.module_from_spec(spec)
455
- sys.modules[spec.name] = module
522
+ sys.modules[name] = module
456
523
  spec.loader.exec_module(module)
457
524
  return module
458
525
 
459
526
 
460
- def load_candidate_landing_profile(core, wt: str):
461
- """Let the committed worktree policy nominate evidence without deletion."""
462
- result = core.run(
463
- [
464
- "git", "show",
465
- "HEAD:docs/agents/workflow-capabilities.json",
466
- ],
467
- cwd=Path(wt),
468
- check=False,
469
- )
470
- if result.returncode != 0:
471
- detail = (result.stderr or result.stdout).strip()
472
- raise core.LifecycleError(
473
- f"committed landing profile cannot be read from worktree HEAD: {detail}"
474
- )
475
- candidate = core.load_profile_text(result.stdout)
476
- if not candidate.landing_generated_artifact_policy_configured:
477
- raise core.LifecycleError(
478
- "worktree landing artifact policy is not configured; "
479
- "run setup-workflow and commit the explicit policy decision first"
480
- )
481
- return candidate
482
-
483
-
484
- def load_merged_canonical_profile(core, main_tree: str):
485
- """Read the merged canonical cleanup policy from origin/main."""
486
- result = core.run(
487
- [
488
- "git", "show",
489
- "origin/main:docs/agents/workflow-capabilities.json",
490
- ],
491
- cwd=Path(main_tree),
492
- check=False,
493
- )
494
- if result.returncode != 0:
495
- detail = (result.stderr or result.stdout).strip()
496
- raise core.LifecycleError(
497
- f"canonical landing profile cannot be read from origin/main: {detail}"
498
- )
499
- canonical = core.load_profile_text(result.stdout)
500
- if not canonical.landing_generated_artifact_policy_configured:
501
- raise core.LifecycleError(
502
- "merged canonical landing artifact policy is not configured"
503
- )
504
- return canonical
527
+ def load_teardown_classifier():
528
+ """The one stateless teardown core never a second copy here."""
529
+ path = Path(__file__).resolve().parent / "worktree-lifecycle" / "classify.py"
530
+ return load_module(CLASSIFY_MODULE, path, "4 teardown")
505
531
 
506
532
 
507
- def load_canonical_landing_profile(core, wt: str, main_tree: str):
508
- """Authorize deletion only after the candidate policy is canonical."""
509
- candidate = load_candidate_landing_profile(core, wt)
510
- canonical = load_merged_canonical_profile(core, main_tree)
511
- if (
512
- candidate.landing_generated_artifact_patterns
513
- != canonical.landing_generated_artifact_patterns
514
- or candidate.scratch_patterns != canonical.scratch_patterns
515
- ):
516
- raise core.LifecycleError(
517
- "worktree cleanup policy differs from merged canonical origin/main"
518
- f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
519
- )
520
- if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
521
- raise core.LifecycleError(
522
- "landing attempt is missing before canonical cleanup authorization"
523
- )
524
- attempt = core.landing_start_artifact_inventory(candidate, Path(wt))
525
- if (
526
- attempt["policyDigest"]
527
- != core.landing_cleanup_policy_digest(canonical)
528
- ):
529
- raise core.LifecycleError(
530
- "landing attempt cleanup policy differs from merged canonical origin/main"
531
- f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
532
- )
533
- return canonical
533
+ def load_lifecycle_profile_module():
534
+ path = Path(__file__).resolve().parent / "worktree-lifecycle" / "profile.py"
535
+ return load_module(LIFECYCLE_PROFILE_MODULE, path, "profile")
534
536
 
535
537
 
536
- def load_canonical_recovery_profile(core, wt: str, main_tree: str):
537
- """Authorize post-merge cleanup from canonical policy alone after drift.
538
+ def lifecycle_settings(repo_root: str) -> dict:
539
+ """The consumer's Worktree Lifecycle block, or {} when there is none.
538
540
 
539
- The committed worktree candidate is deliberately never consulted here: after
540
- canonical drift it is stale, and trusting it could only widen what may be
541
- deleted. Every authority boundary re-validates the frozen evidence against
542
- the merged canonical policy instead.
541
+ Read raw rather than through the module's loader: these structural facts —
542
+ branch names, branch templates are needed whether or not the consumer
543
+ enabled the worktree lifecycle itself.
543
544
  """
544
- canonical = load_merged_canonical_profile(core, main_tree)
545
- core.canonical_recovery_evidence(canonical, Path(wt))
546
- return canonical
547
-
548
-
549
- def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
550
- core = load_worktree_cleanup_core()
551
- try:
552
- profile = load_candidate_landing_profile(core, wt)
553
- return core.landing_start_artifact_inventory(profile, Path(wt))
554
- except core.LegacyLandingAttempt as error:
555
- # Legacy, not damage: report the classification and the safe route out
556
- # verbatim, without the corruption framing of a guard failure.
557
- raise Stop("cleanup", str(error)) from error
558
- except core.LifecycleError as error:
559
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
560
-
561
-
562
- def landing_verified_scratch_evidence(
563
- wt: str,
564
- main_tree: str,
565
- *,
566
- expected_baseline_digest: str | None = None,
567
- landing_start_files: tuple[str, ...] = (),
568
- ) -> tuple[dict, ...]:
569
- core = load_worktree_cleanup_core()
570
545
  try:
571
- profile = load_candidate_landing_profile(core, wt)
572
- return core.verified_landing_scratch_evidence(
573
- profile,
574
- Path(wt),
575
- expected_baseline_digest=expected_baseline_digest,
576
- landing_start_files=landing_start_files,
546
+ document = json.loads(
547
+ (Path(repo_root) / LIFECYCLE_PROFILE).read_text(encoding="utf-8")
577
548
  )
578
- except core.LifecycleError as error:
579
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
549
+ candidate = document.get("worktreeLifecycle")
550
+ except (OSError, json.JSONDecodeError, AttributeError, TypeError):
551
+ return {}
552
+ return candidate if isinstance(candidate, dict) else {}
580
553
 
581
554
 
582
- def freeze_landing_artifact_evidence(
583
- wt: str,
584
- main_tree: str,
585
- *,
586
- push_succeeded: bool,
587
- ) -> tuple[dict, ...]:
588
- core = load_worktree_cleanup_core()
589
- try:
590
- profile = load_candidate_landing_profile(core, wt)
591
- return core.freeze_landing_artifact_evidence(
592
- profile,
593
- Path(wt),
594
- push_succeeded=push_succeeded,
595
- )
596
- except core.LifecycleError as error:
597
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
555
+ def branch_policy(repo_root: str) -> tuple[str, tuple[str, ...]]:
556
+ """Return (integration branch, protected branches) from the consumer profile.
598
557
 
558
+ The integration branch is never named inline. An absent or malformed
559
+ profile falls back to the Worktree Lifecycle profile's own documented
560
+ default, which is the single place in the kit that names a branch at all.
561
+ """
562
+ defaults = load_lifecycle_profile_module().DEFAULT_MAIN_BRANCHES
563
+ raw = lifecycle_settings(repo_root)
564
+ integration = tuple(raw.get("mainBranches") or defaults)
565
+ protected = tuple(raw.get("protectedBranches") or integration)
566
+ return integration[0], protected
599
567
 
600
- def reopen_frozen_landing_attempt(wt: str, main_tree: str) -> tuple[dict, ...]:
601
- core = load_worktree_cleanup_core()
602
- try:
603
- profile = load_candidate_landing_profile(core, wt)
604
- return core.reopen_frozen_landing_attempt(profile, Path(wt))
605
- except core.LifecycleError as error:
606
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
607
568
 
569
+ def require_landable_head(step: str, cwd: str) -> str:
570
+ """Return the worktree's branch, or STOP with the exact reason it has none.
608
571
 
609
- def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
610
- core = load_worktree_cleanup_core()
611
- try:
612
- return str(core.abandon_unfinished_landing_attempt(Path(wt)))
613
- except core.LifecycleError as error:
614
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
572
+ Detached and unborn are the two states a landing can never repair on its
573
+ own, so each is a named refusal that says what the user does about it —
574
+ never a silent skip.
575
+ """
576
+ symbolic = git(["symbolic-ref", "--quiet", "HEAD"], cwd=cwd)
577
+ if symbolic.returncode != 0:
578
+ raise Stop(
579
+ step,
580
+ "detached HEAD — /wrapup lands a branch and this worktree is on none",
581
+ f"{cwd}: attach one here (`git switch -c <branch>` keeps the work on a "
582
+ "new branch, `git switch <branch>` moves to an existing one), then "
583
+ "re-run /wrapup",
584
+ )
585
+ branch = symbolic.stdout.strip().removeprefix("refs/heads/")
586
+ if git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd=cwd).returncode != 0:
587
+ raise Stop(
588
+ step,
589
+ f"unborn branch {branch} — it has no commits yet, so there is nothing to land",
590
+ f"{cwd}: make the first commit (wrapup's `commit` step does it), then "
591
+ "re-run /wrapup",
592
+ )
593
+ return branch
615
594
 
616
595
 
617
- def canonical_recovery_evidence(wt: str, main_tree: str) -> tuple[dict, ...]:
618
- """Revalidate the frozen landing evidence against canonical policy alone."""
619
- core = load_worktree_cleanup_core()
596
+ def assess_teardown(wt: str, main_tree: str):
597
+ """Classify the worktree's current state the only teardown authority."""
598
+ classify = load_teardown_classifier()
620
599
  try:
621
- canonical = load_merged_canonical_profile(core, main_tree)
622
- return core.canonical_recovery_evidence(canonical, Path(wt))
623
- except core.LifecycleError as error:
624
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
600
+ return classify.assess(Path(wt), Path(main_tree))
601
+ except classify.ClassificationError as error:
602
+ raise Stop("4 teardown", "teardown cannot be classified", str(error)) from error
625
603
 
626
604
 
627
- def landing_verified_scratch_files(
628
- wt: str,
629
- main_tree: str,
630
- *,
631
- expected_baseline_digest: str | None = None,
632
- ) -> tuple[str, ...]:
633
- core = load_worktree_cleanup_core()
605
+ def remove_teardown_scratch(assessment) -> list[str]:
606
+ """Delete exactly what the given assessment cleared, re-checked entry by entry."""
607
+ classify = load_teardown_classifier()
634
608
  try:
635
- profile = load_candidate_landing_profile(core, wt)
636
- return core.verified_landing_scratch_files(
637
- profile,
638
- Path(wt),
639
- expected_baseline_digest=expected_baseline_digest,
640
- )
641
- except core.LifecycleError as error:
642
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
609
+ return list(classify.remove_scratch(assessment))
610
+ except classify.ClassificationError as error:
611
+ raise Stop("4 teardown", "teardown is blocked", str(error)) from error
643
612
 
644
613
 
645
- def ensure_worktree_removable(
646
- wt: str,
647
- main_tree: str,
648
- *,
649
- verified_scratch_files: tuple[str, ...] = (),
650
- verified_scratch_evidence: tuple[dict, ...] = (),
651
- profile_loader=None,
652
- ):
653
- core = load_worktree_cleanup_core()
654
- # Resolved at call time so the module attribute stays the single seam.
655
- loader = profile_loader or load_canonical_landing_profile
656
- try:
657
- profile = loader(core, wt, main_tree)
658
- kwargs = {"merge_target": "origin/main"}
659
- paths = (
660
- tuple(item["path"] for item in verified_scratch_evidence)
661
- or verified_scratch_files
662
- )
663
- if paths:
664
- kwargs["verified_scratch_files"] = paths
665
- if verified_scratch_evidence:
666
- kwargs["verified_scratch_evidence"] = verified_scratch_evidence
667
- assessment = core.cleanup_assessment(
668
- profile, Path(main_tree), Path(wt), **kwargs
669
- )
670
- except core.LifecycleError as error:
671
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
672
- if assessment.reasons:
673
- raise Stop(
674
- "cleanup",
675
- "shared cleanup guard refused worktree removal",
676
- "; ".join(assessment.reasons),
677
- )
678
- return assessment
679
-
680
-
681
- def remove_verified_worktree_scratch(
682
- wt: str,
683
- main_tree: str,
684
- assessment,
685
- *,
686
- verified_scratch_files: tuple[str, ...] = (),
687
- verified_scratch_evidence: tuple[dict, ...] = (),
688
- profile_loader=None,
689
- ):
690
- """Re-verify and delete only assessed regular scratch files."""
691
- core = load_worktree_cleanup_core()
692
- loader = profile_loader or load_canonical_landing_profile
693
- try:
694
- profile = loader(core, wt, main_tree)
695
- evidence = verified_scratch_evidence
696
- paths = tuple(item["path"] for item in evidence) or verified_scratch_files
697
- latest = core.cleanup_assessment(
698
- profile,
699
- Path(main_tree),
700
- Path(wt),
701
- merge_target="origin/main",
702
- verified_scratch_files=paths,
703
- verified_scratch_evidence=evidence,
704
- )
705
- if latest.reasons:
706
- raise core.LifecycleError(
707
- "cleanup changed before removal: " + "; ".join(latest.reasons)
708
- )
709
- if (
710
- latest.branch != assessment.branch
711
- or latest.scratch_files != assessment.scratch_files
712
- or latest.assumptions != assessment.assumptions
713
- or latest.root_device != assessment.root_device
714
- or latest.root_inode != assessment.root_inode
715
- or latest.scratch_evidence != assessment.scratch_evidence
716
- ):
717
- raise core.LifecycleError(
718
- "cleanup changed before removal: inventory no longer matches preview"
719
- )
720
- with core.verified_worktree_root(
721
- latest.worktree,
722
- latest.root_device,
723
- latest.root_inode,
724
- ) as root_descriptor:
725
- core.remove_authorized_scratch(
726
- profile,
727
- root_descriptor,
728
- latest.scratch_files,
729
- latest.scratch_evidence,
730
- )
731
- final = core.cleanup_assessment(
732
- profile,
733
- Path(main_tree),
734
- Path(wt),
735
- merge_target="origin/main",
736
- )
737
- if final.reasons:
738
- raise core.LifecycleError(
739
- "cleanup changed after scratch removal: " + "; ".join(final.reasons)
740
- )
741
- return final
742
- except core.LifecycleError as error:
743
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
614
+ def teardown_report(assessment) -> str:
615
+ return load_teardown_classifier().render_report(assessment)
744
616
 
745
617
 
746
618
  # ---------- drift log (ANNAHMEN.md) ----------
@@ -827,33 +699,102 @@ def self_ancestry() -> set[int]:
827
699
  return pids
828
700
 
829
701
 
702
+ def process_identity(pid: int) -> tuple[str, str] | None:
703
+ """The pair that tells one PID apart from its recycled reuse: start time + cwd."""
704
+ try:
705
+ with open(f"/proc/{pid}/stat", "rb") as handle:
706
+ fields = handle.read().rsplit(b")", 1)[1].split()
707
+ started = fields[19].decode()
708
+ return started, os.path.realpath(f"/proc/{pid}/cwd")
709
+ except (OSError, IndexError):
710
+ return None
711
+
712
+
713
+ def signal_listener(port: str, pid: int, identity: tuple[str, str]) -> str:
714
+ """SIGKILL one attributed listener without ever hitting a recycled PID.
715
+
716
+ `pidfd_open` (Linux 5.3+) pins the process, so the signal cannot land on a
717
+ PID some other process inherited between the lookup and the kill. Without
718
+ it the identity is re-read immediately before signalling — a narrower but
719
+ honest guarantee, and still a refusal rather than a blind kill.
720
+ """
721
+ opener = getattr(os, "pidfd_open", None)
722
+ sender = getattr(signal, "pidfd_send_signal", None)
723
+ if opener is None or sender is None:
724
+ if process_identity(pid) != identity:
725
+ raise Stop(
726
+ "2 process-kill",
727
+ f"pid {pid} changed identity before it could be signalled",
728
+ "the PID was recycled; re-run land once the ports are quiet",
729
+ )
730
+ try:
731
+ os.kill(pid, signal.SIGKILL)
732
+ except ProcessLookupError:
733
+ return ""
734
+ return f"port {port} pid {pid} (recheck)"
735
+ try:
736
+ descriptor = opener(pid)
737
+ except ProcessLookupError:
738
+ return ""
739
+ except OSError as error:
740
+ raise Stop(
741
+ "2 process-kill", f"cannot pin pid {pid} before signalling it", str(error)
742
+ ) from error
743
+ try:
744
+ if process_identity(pid) != identity:
745
+ raise Stop(
746
+ "2 process-kill",
747
+ f"pid {pid} changed identity before it could be signalled",
748
+ "the PID was recycled; re-run land once the ports are quiet",
749
+ )
750
+ signal.pidfd_send_signal(descriptor, signal.SIGKILL)
751
+ except ProcessLookupError:
752
+ return ""
753
+ finally:
754
+ os.close(descriptor)
755
+ return f"port {port} pid {pid} (pidfd)"
756
+
757
+
830
758
  def kill_worktree_processes(wt: str) -> list[str]:
831
- """Kill port listeners (.dev-ports) + processes with cwd under the worktree.
759
+ """Signal only the listeners on this worktree's own declared `.dev-ports`.
832
760
 
833
- Own shell ancestry is excluded (the self-kill trap, mechanically)."""
834
- killed, protected = [], self_ancestry()
835
- ports = []
761
+ Never signal on doubt. A listener whose working directory is not inside
762
+ this worktree or whose identity cannot be read at all — is a named STOP,
763
+ not a kill: matching foreign processes by command name across the whole
764
+ machine is how a wrapup run takes down someone else's server. Own shell
765
+ ancestry stays excluded (the self-kill trap).
766
+ """
836
767
  dev_ports = Path(wt) / ".dev-ports"
837
- if dev_ports.is_file():
838
- ports = parse_dev_ports(dev_ports.read_text())
768
+ if not dev_ports.is_file():
769
+ return []
770
+ ports = parse_dev_ports(dev_ports.read_text())
771
+ protected = self_ancestry()
772
+ worktree_root = os.path.realpath(wt)
773
+ killed, unattributed = [], []
839
774
  for port in ports:
840
- p = run(["lsof", f"-ti:{port}"])
841
- for pid in p.stdout.split():
842
- if pid.isdigit() and int(pid) not in protected:
843
- run(["kill", "-9", pid])
844
- killed.append(f"port {port} pid {pid}")
845
- p = run(["pgrep", "-f", "tsx|vite|tsc|pnpm|node"])
846
- wt_real = os.path.realpath(wt)
847
- for pid in p.stdout.split():
848
- if not pid.isdigit() or int(pid) in protected:
849
- continue
850
- try:
851
- cwd = os.path.realpath(f"/proc/{pid}/cwd")
852
- except OSError:
853
- continue
854
- if cwd == wt_real or cwd.startswith(wt_real + os.sep):
855
- run(["kill", pid])
856
- killed.append(f"cwd pid {pid}")
775
+ listing = run(["lsof", "-ti", f":{port}"])
776
+ for raw in listing.stdout.split():
777
+ if not raw.isdigit() or int(raw) in protected:
778
+ continue
779
+ pid = int(raw)
780
+ identity = process_identity(pid)
781
+ if identity is None:
782
+ unattributed.append(f"port {port} pid {pid}: identity unreadable")
783
+ continue
784
+ cwd = identity[1]
785
+ if cwd != worktree_root and not cwd.startswith(worktree_root + os.sep):
786
+ unattributed.append(f"port {port} pid {pid}: cwd {cwd}")
787
+ continue
788
+ entry = signal_listener(port, pid, identity)
789
+ if entry:
790
+ killed.append(entry)
791
+ if unattributed:
792
+ raise Stop(
793
+ "2 process-kill",
794
+ "a process on this worktree's declared ports cannot be attributed to it "
795
+ "— never signal on doubt",
796
+ "; ".join(unattributed),
797
+ )
857
798
  return killed
858
799
 
859
800
 
@@ -874,8 +815,13 @@ def anchor_complete_from_body(body: str) -> bool | None:
874
815
 
875
816
  # ---------- remote sweep set ----------
876
817
 
877
- def stale_remote_set(merged: set[str], remotes: set[str], open_prs: set[str]) -> list[str]:
878
- return sorted((merged & remotes) - open_prs - {"main"})
818
+ def stale_remote_set(
819
+ merged: set[str],
820
+ remotes: set[str],
821
+ open_prs: set[str],
822
+ protected: set[str],
823
+ ) -> list[str]:
824
+ return sorted((merged & remotes) - open_prs - protected)
879
825
 
880
826
 
881
827
  # ---------- subcommands ----------
@@ -883,11 +829,18 @@ def stale_remote_set(merged: set[str], remotes: set[str], open_prs: set[str]) ->
883
829
  def cmd_preflight(args) -> dict:
884
830
  cwd = os.getcwd()
885
831
  main_tree, _ = worktree_map(cwd)
886
- branch = git(["branch", "--show-current"], check=True).stdout.strip()
887
832
  wt = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
888
- if os.path.realpath(wt) == os.path.realpath(main_tree) or branch == "main":
889
- raise Stop("preflight", "not in a feature worktree",
890
- f"wt={wt} branch={branch} /wrapup runs in the finished slice's worktree")
833
+ branch = require_landable_head("preflight", cwd)
834
+ _, protected = branch_policy(wt)
835
+ # No naming or location gate: any born, attached worktree is first-class,
836
+ # including one an external tool created under a foreign name and path.
837
+ # Only the main checkout and a protected branch are refused.
838
+ if os.path.realpath(wt) == os.path.realpath(main_tree):
839
+ raise Stop("preflight", "run /wrapup in the worktree it should land and tear down",
840
+ f"wt={wt} is the main checkout — /wrapup never tears that down")
841
+ if branch in protected:
842
+ raise Stop("preflight", f"{branch} is a protected branch",
843
+ f"wt={wt} — /wrapup lands a slice branch, never the integration branch")
891
844
 
892
845
  porcelain = git(["status", "--porcelain"], check=True).stdout
893
846
  profile = load_profile()
@@ -941,9 +894,16 @@ def cmd_preflight(args) -> dict:
941
894
 
942
895
 
943
896
  def cmd_commit(args) -> dict:
944
- branch = git(["branch", "--show-current"], check=True).stdout.strip()
945
- if branch == "main":
946
- raise Stop("commit", "on main — refusing to commit")
897
+ cwd = os.getcwd()
898
+ root = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
899
+ _, protected = branch_policy(root)
900
+ symbolic = git(["symbolic-ref", "--quiet", "HEAD"], cwd=cwd)
901
+ branch = symbolic.stdout.strip().removeprefix("refs/heads/") if symbolic.returncode == 0 else ""
902
+ if not branch:
903
+ raise Stop("commit", "detached HEAD — refusing to commit onto no branch",
904
+ f"{root}: attach a branch here (`git switch -c <branch>`), then re-run")
905
+ if branch in protected:
906
+ raise Stop("commit", f"on protected branch {branch} — refusing to commit")
947
907
  porcelain = git(["status", "--porcelain"], check=True).stdout
948
908
  if not porcelain.strip():
949
909
  return {"committed": False, "reason": "clean tree — nothing to commit"}
@@ -968,107 +928,901 @@ def cmd_commit(args) -> dict:
968
928
  return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
969
929
 
970
930
 
971
- def retire_local_branch(branch: str, main_tree: str, report: dict) -> None:
972
- """Fast-forward main, then retire the merged local branch with `-d` only."""
931
+ # ---------- Content route (durable content, no worktree) ----------
932
+ #
933
+ # A planning session has no worktree and no slice. What it produced — an ADR, a
934
+ # glossary update, a research note — sits dirty in the main checkout on the
935
+ # protected branch, and this is its landing door. Inference proposes; the user's
936
+ # explicit, hash-carrying claim decides; every claimed path is re-read
937
+ # immediately before staging; everything else in that tree is a bystander and
938
+ # comes out untouched. There is no teardown half here: the route lands and
939
+ # stops. It is invoked, never chained — no other route falls back into it.
940
+
941
+ CONTENT_CLAIM_STEP = "content claim"
942
+ CONTENT_STEP = "content commit"
943
+ CONTENT_RETURN_STEP = "content return"
944
+ CONTENT_CANDIDATE_LIMIT = 200
945
+ CONTENT_TOP_DIRECTORY_LIMIT = 5
946
+
947
+
948
+ class Claimed(NamedTuple):
949
+ """One path with the blob identity it carried when it was read."""
950
+
951
+ path: str
952
+ oid: str
953
+ mode: str
954
+
955
+ def as_record(self) -> dict:
956
+ return {"path": self.path, "oid": self.oid, "mode": self.mode}
957
+
958
+
959
+ def content_context(step: str) -> tuple[str, str, tuple[str, ...]]:
960
+ """The route's three preconditions, each a named refusal.
961
+
962
+ It lands what a planning session left in the main checkout, so a worktree
963
+ and an unprotected branch both belong to the ordinary route, and a detached
964
+ or unborn HEAD is a state no landing can repair for you.
965
+ """
966
+ main_tree, _ = worktree_map()
967
+ here = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
968
+ if os.path.realpath(here) != os.path.realpath(main_tree):
969
+ raise Stop(
970
+ step, "the Content route lands from the main checkout",
971
+ f"cwd={here} is a worktree — a slice lands through preflight/commit/land",
972
+ )
973
+ branch = require_landable_head(step, here)
974
+ _, protected = branch_policy(main_tree)
975
+ if branch not in protected:
976
+ raise Stop(
977
+ step, f"{branch} is not a protected branch",
978
+ "the Content route lands what a planning session left on the protected "
979
+ "branch; this branch lands through the ordinary route",
980
+ )
981
+ return main_tree, branch, protected
982
+
983
+
984
+ def _zsplit(payload: str) -> list[str]:
985
+ return [part for part in payload.split("\0") if part]
986
+
987
+
988
+ def content_sources(main_checkout: str, step: str) -> tuple[list[str], list[str]]:
989
+ """Split the dirty tree into claimable paths and named unclaimable ones.
990
+
991
+ Teardown classification lives in worktree-lifecycle/classify.py and stays
992
+ there. This asks a different question — what durable content is dirty — and
993
+ reads the two plumbing lists that answer it directly, so the kit keeps
994
+ exactly one porcelain-status parser and this is not a second one.
995
+ """
996
+ paths, unclaimable = [], []
997
+ fields = iter(_zsplit(
998
+ git(["diff", "--name-status", "-z", "HEAD"], cwd=main_checkout, check=True).stdout
999
+ ))
1000
+ for letter in fields:
1001
+ path = next(fields, "")
1002
+ if not path:
1003
+ raise Stop(step, "malformed git diff record", letter)
1004
+ if letter[:1] in {"R", "C"}:
1005
+ renamed = next(fields, "")
1006
+ unclaimable.append(
1007
+ f"{path} → {renamed} — a staged rename lands through the ordinary route"
1008
+ )
1009
+ elif letter[:1] == "D":
1010
+ unclaimable.append(f"{path} — a deletion is not durable content this route lands")
1011
+ else:
1012
+ paths.append(path)
1013
+ paths.extend(_zsplit(
1014
+ git(["ls-files", "--others", "--exclude-standard", "-z"],
1015
+ cwd=main_checkout, check=True).stdout
1016
+ ))
1017
+ return paths, unclaimable
1018
+
1019
+
1020
+ def content_oid(main_checkout: str, path: str, *, write: bool = False) -> str:
1021
+ """The blob OID git itself would record for this path, filters included."""
1022
+ args = ["hash-object", *(["-w"] if write else []), "--", path]
1023
+ result = git(args, cwd=main_checkout)
1024
+ return result.stdout.strip() if result.returncode == 0 else ""
1025
+
1026
+
1027
+ def _blob_mode(metadata) -> str:
1028
+ return "100755" if metadata.st_mode & 0o111 else "100644"
1029
+
1030
+
1031
+ def _content_candidate(main_checkout: str, path: str) -> tuple[Claimed | None, str]:
1032
+ """Judge one dirty path: a claimable candidate, or the reason it is not."""
1033
+ if ENV_PATH_RE.search(path):
1034
+ return None, "a .env* file never lands through any route"
1035
+ try:
1036
+ metadata = os.lstat(Path(main_checkout) / path)
1037
+ except OSError as error:
1038
+ return None, f"cannot be read ({error.strerror})"
1039
+ if stat.S_ISLNK(metadata.st_mode):
1040
+ return None, "a symlink is not durable content this route lands"
1041
+ if not stat.S_ISREG(metadata.st_mode):
1042
+ return None, "not a regular file"
1043
+ oid = content_oid(main_checkout, path)
1044
+ if not oid:
1045
+ return None, "git cannot hash it"
1046
+ return Claimed(path, oid, _blob_mode(metadata)), ""
1047
+
1048
+
1049
+ def _content_summary(paths: list[str]) -> str:
1050
+ """Bounded: the count plus the top directories, never a path dump."""
1051
+ counts = Counter(str(PurePosixPath(path).parent) for path in paths)
1052
+ top = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
1053
+ listed = ", ".join(
1054
+ f"{'./' if name == '.' else name + '/'} ({count} files)"
1055
+ for name, count in top[:CONTENT_TOP_DIRECTORY_LIMIT]
1056
+ )
1057
+ return (
1058
+ f"{len(paths)} dirty paths in {len(counts)} directories: {listed} — ignore or "
1059
+ "remove the bulk, then run the claim again"
1060
+ )
1061
+
1062
+
1063
+ def content_candidates(main_checkout: str, step: str) -> tuple[list[Claimed], list[str]]:
1064
+ """Infer the durable content a session left dirty. Inference only proposes."""
1065
+ paths, unclaimable = content_sources(main_checkout, step)
1066
+ if len(paths) > CONTENT_CANDIDATE_LIMIT:
1067
+ raise Stop(
1068
+ step, "the dirty tree is too large to infer durable content from",
1069
+ _content_summary(paths),
1070
+ )
1071
+ candidates = []
1072
+ for path in sorted(paths):
1073
+ entry, problem = _content_candidate(main_checkout, path)
1074
+ if entry is None:
1075
+ unclaimable.append(f"{path} — {problem}")
1076
+ else:
1077
+ candidates.append(entry)
1078
+ return candidates, sorted(unclaimable)
1079
+
1080
+
1081
+ def load_claim(path: str) -> list[Claimed]:
1082
+ """Read the confirmed claim: concrete paths, each with the hash it carried.
1083
+
1084
+ The recorded hash is what makes the claim verifiable, so a record without
1085
+ one is a refusal — re-hashing at staging time would confirm nothing. A
1086
+ record's `mode` travels for a lossless round-trip of the claim report and
1087
+ is never the identity: content is, and the file lands with the mode it has.
1088
+ """
1089
+ try:
1090
+ document = json.loads(Path(path).read_text(encoding="utf-8"))
1091
+ except (OSError, json.JSONDecodeError) as error:
1092
+ raise Stop(CONTENT_STEP, "the claim file cannot be read", str(error)) from error
1093
+ records = document.get("claimed") if isinstance(document, dict) else document
1094
+ if not isinstance(records, list) or not records:
1095
+ raise Stop(CONTENT_STEP, "the claim names no path",
1096
+ 'expected {"claimed": [{"path": ..., "oid": ...}, ...]}')
1097
+ claim = []
1098
+ for record in records:
1099
+ path_value = record.get("path") if isinstance(record, dict) else None
1100
+ oid_value = record.get("oid") if isinstance(record, dict) else None
1101
+ if not isinstance(path_value, str) or not isinstance(oid_value, str) or not oid_value:
1102
+ raise Stop(
1103
+ CONTENT_STEP, "every claimed path needs the hash it was claimed with",
1104
+ json.dumps(record, ensure_ascii=False)[:200],
1105
+ )
1106
+ claim.append(Claimed(path_value, oid_value, str(record.get("mode") or "")))
1107
+ return claim
1108
+
1109
+
1110
+ def _is_ignored(main_checkout: str, path: str) -> bool:
1111
+ """Git's own exclude sources decide what is Scratch."""
1112
+ return git(["check-ignore", "-q", "--", path], cwd=main_checkout).returncode == 0
1113
+
1114
+
1115
+ def verify_claim(main_checkout: str, claim: list[Claimed], candidates: list[Claimed]):
1116
+ """Match the claim against the tree as it is right now.
1117
+
1118
+ A hard block stops the whole route. A path whose content moved since the
1119
+ claim is dropped and named instead — a claim confirms exactly what the user
1120
+ read, so a changed file was never confirmed, and the rest still lands.
1121
+ """
1122
+ inferred = {entry.path: entry for entry in candidates}
1123
+ survivors, dropped, seen = [], [], set()
1124
+ for entry in claim:
1125
+ if ENV_PATH_RE.search(entry.path):
1126
+ raise Stop(CONTENT_STEP, "a .env* path was claimed — never commit one",
1127
+ entry.path)
1128
+ if entry.path in seen:
1129
+ continue
1130
+ seen.add(entry.path)
1131
+ current = inferred.get(entry.path)
1132
+ if current is None:
1133
+ if _is_ignored(main_checkout, entry.path):
1134
+ raise Stop(
1135
+ CONTENT_STEP,
1136
+ "an ignored path was claimed — ignored content is scratch, never "
1137
+ "durable content",
1138
+ entry.path,
1139
+ )
1140
+ dropped.append(f"{entry.path} — no longer dirty durable content here")
1141
+ elif current.oid != entry.oid:
1142
+ dropped.append(
1143
+ f"{entry.path} — changed between the claim and staging "
1144
+ f"({entry.oid[:7]} → {current.oid[:7]})"
1145
+ )
1146
+ else:
1147
+ survivors.append(current)
1148
+ if not survivors:
1149
+ raise Stop(CONTENT_STEP, "nothing left to land — every claimed path dropped",
1150
+ "\n".join(dropped))
1151
+ return survivors, dropped
1152
+
1153
+
1154
+ def stage_content(main_checkout: str, claimed: list[Claimed]) -> str:
1155
+ """Write exactly the claimed paths into a private index; return the tree.
1156
+
1157
+ Neither the repository index nor the working tree is touched here: the index
1158
+ is a throwaway file seeded from HEAD, and each entry is written by the blob
1159
+ OID the claim was verified against. No pathspec walk exists to widen, so
1160
+ `git add -A`, `git add .` and `git commit -a` are unreachable from this
1161
+ route rather than merely unused.
1162
+ """
1163
+ with tempfile.TemporaryDirectory() as scratch:
1164
+ environment = dict(os.environ, GIT_INDEX_FILE=str(Path(scratch) / "index"))
1165
+ git(["read-tree", "HEAD"], cwd=main_checkout, check=True, env=environment)
1166
+ for entry in claimed:
1167
+ written = content_oid(main_checkout, entry.path, write=True)
1168
+ if written != entry.oid:
1169
+ raise Stop(
1170
+ CONTENT_STEP, f"{entry.path} changed while it was being staged",
1171
+ f"{entry.oid[:7]} → {written[:7] or 'unreadable'}; run the claim again",
1172
+ )
1173
+ git(["update-index", "--add", "--cacheinfo",
1174
+ f"{entry.mode},{entry.oid},{entry.path}"],
1175
+ cwd=main_checkout, check=True, env=environment)
1176
+ return git(["write-tree"], cwd=main_checkout, check=True,
1177
+ env=environment).stdout.strip()
1178
+
1179
+
1180
+ def verify_staged_tree(main_checkout: str, tree: str, claimed: list[Claimed]) -> None:
1181
+ """Assert the staged tree carries exactly the claim — by name and by OID."""
1182
+ changed = sorted(_zsplit(
1183
+ git(["diff-tree", "-r", "--name-only", "-z", "HEAD", tree],
1184
+ cwd=main_checkout, check=True).stdout
1185
+ ))
1186
+ expected = sorted(entry.path for entry in claimed)
1187
+ if changed != expected:
1188
+ raise Stop(CONTENT_STEP, "the staged tree is not exactly the claim",
1189
+ f"staged {changed} against the claim {expected}")
1190
+ for entry in claimed:
1191
+ listed = git(["ls-tree", "--full-name", tree, "--", entry.path],
1192
+ cwd=main_checkout, check=True).stdout.split()
1193
+ if listed[:3] != [entry.mode, "blob", entry.oid]:
1194
+ raise Stop(CONTENT_STEP, f"{entry.path} is staged as a different object",
1195
+ " ".join(listed[:3]) or "absent from the staged tree")
1196
+
1197
+
1198
+ def content_secret_gate(main_checkout: str, tree: str, allow_matches: bool) -> list[str]:
1199
+ """The ordinary secret scan, run on exactly the diff this route will commit."""
1200
+ patch = git(["diff-tree", "-p", "HEAD", tree], cwd=main_checkout, check=True).stdout
1201
+ hits = secret_hits_in(patch)
1202
+ if hits and not allow_matches:
1203
+ raise Stop(
1204
+ CONTENT_STEP,
1205
+ "possible secrets in the claimed content — review; false positive → re-run "
1206
+ "with --allow-matches, real secret → resolve first",
1207
+ "\n".join(hits[:40]),
1208
+ )
1209
+ return hits
1210
+
1211
+
1212
+ def content_branch_name(main_checkout: str, slug: str, branch_type: str) -> str:
1213
+ """Render the issue-less branch from the profile's content template."""
1214
+ module = load_lifecycle_profile_module()
1215
+ template = (lifecycle_settings(main_checkout).get("contentBranchTemplate")
1216
+ or module.DEFAULT_CONTENT_BRANCH_TEMPLATE)
1217
+ try:
1218
+ branch = module.render_content_branch(template, slug, branch_type)
1219
+ except module.LifecycleError as error:
1220
+ raise Stop(CONTENT_STEP, "the content branch template cannot be rendered",
1221
+ str(error)) from error
1222
+ if git(["check-ref-format", "--branch", branch], cwd=main_checkout).returncode != 0:
1223
+ raise Stop(CONTENT_STEP, f"{branch} is not a valid branch name",
1224
+ "choose another slug or branch type")
1225
+ return branch
1226
+
1227
+
1228
+ def check_content_collision(main_checkout: str, branch: str) -> list[str]:
1229
+ """A branch that exists anywhere is a collision — never reuse, never overwrite."""
1230
+ advice = "the Content route never reuses or overwrites a branch — choose another slug"
1231
+ if branch_tip(main_checkout, branch):
1232
+ raise Stop(CONTENT_STEP, f"branch {branch} already exists locally", advice)
1233
+ listed = git(["ls-remote", "--heads", "origin", branch], cwd=main_checkout)
1234
+ if listed.returncode != 0:
1235
+ return [
1236
+ f"the remote could not be queried, so the collision check for {branch} was "
1237
+ f"local only: {sanitize_external_detail(listed.stderr or listed.stdout)}"
1238
+ ]
1239
+ if listed.stdout.strip():
1240
+ raise Stop(CONTENT_STEP, f"branch {branch} already exists on the remote", advice)
1241
+ return []
1242
+
1243
+
1244
+ def cut_content_branch(main_checkout: str, branch: str, tree: str, message: str,
1245
+ claimed: list[Claimed]) -> str:
1246
+ """Cut the branch onto the verified tree, refreshing only the claimed paths."""
1247
+ paths = [entry.path for entry in claimed]
1248
+ parent = git(["rev-parse", "HEAD"], cwd=main_checkout, check=True).stdout.strip()
1249
+ commit = git(["commit-tree", tree, "-p", parent, "-m", message],
1250
+ cwd=main_checkout, check=True).stdout.strip()
1251
+ git(["switch", "-c", branch], cwd=main_checkout, check=True)
1252
+ git(["update-ref", "-m", f"wrapup content route: {branch}", "HEAD", commit, parent],
1253
+ cwd=main_checkout, check=True)
1254
+ git(["reset", "-q", "HEAD", "--", *paths], cwd=main_checkout, check=True)
1255
+ if git(["rev-parse", "HEAD"], cwd=main_checkout, check=True).stdout.strip() != commit:
1256
+ raise Stop(CONTENT_STEP, f"{branch} does not carry the verified commit", commit)
1257
+ if git(["diff", "--quiet", "HEAD", "--", *paths], cwd=main_checkout).returncode != 0:
1258
+ raise Stop(CONTENT_STEP, "the claimed paths are still dirty after the commit",
1259
+ f"{branch} at {commit[:7]} — inspect before running the claim again")
1260
+ return commit
1261
+
1262
+
1263
+ def return_to_protected(main_checkout: str, protected_branch: str, branch: str,
1264
+ commit: str) -> str:
1265
+ """Return the checkout to the protected branch, or stop naming the blocker."""
1266
+ result = git(["switch", protected_branch], cwd=main_checkout)
1267
+ if result.returncode != 0:
1268
+ raise Stop(
1269
+ CONTENT_RETURN_STEP,
1270
+ f"cannot return the main checkout to {protected_branch} — nothing was "
1271
+ "forced and nothing was stashed",
1272
+ f"the content is safe on {branch} at {commit[:7]}, and the main checkout is "
1273
+ f"still on {branch}: " + (result.stderr or result.stdout).strip()[-1000:],
1274
+ )
1275
+ return protected_branch
1276
+
1277
+
1278
+ def content_pr_reference(anchor: str | None, body_file: str | None):
1279
+ """`Part of` the anchor — a planning session's content never closes one."""
1280
+ warnings: list[str] = []
1281
+ if body_file:
1282
+ try:
1283
+ body = Path(body_file).read_text(encoding="utf-8")
1284
+ except OSError as error:
1285
+ raise Stop(CONTENT_STEP, "the PR body file cannot be read", str(error)) from error
1286
+ targets = declared_close_targets(body)
1287
+ if targets:
1288
+ raise Stop(
1289
+ CONTENT_STEP,
1290
+ "the PR body declares a close keyword — a planning session's content "
1291
+ "never closes its anchor",
1292
+ "declared: " + ", ".join(f"#{target}" for target in targets),
1293
+ )
1294
+ if not anchor:
1295
+ return None, warnings
1296
+ marker = (load_profile().get("prMarkers") or {}).get("partOf")
1297
+ if not marker:
1298
+ warnings.append("the board profile names no partOf marker, so no anchor "
1299
+ "reference was rendered")
1300
+ return None, warnings
1301
+ return f"{marker} #{anchor}", warnings
1302
+
1303
+
1304
+ def cmd_content_claim(args) -> dict:
1305
+ """Read-only: propose the durable content, each path with its hash."""
1306
+ main_checkout, branch, _ = content_context(CONTENT_CLAIM_STEP)
1307
+ candidates, unclaimable = content_candidates(main_checkout, CONTENT_CLAIM_STEP)
1308
+ return {
1309
+ "main_checkout": main_checkout,
1310
+ "branch": branch,
1311
+ "candidates": [entry.as_record() for entry in candidates],
1312
+ "unclaimable": unclaimable,
1313
+ "next": "confirm the durable paths with the user, then run content-commit "
1314
+ "--claim-file with exactly those records",
1315
+ }
1316
+
1317
+
1318
+ def cmd_content_commit(args) -> dict:
1319
+ """Land the confirmed claim: verify, cut, commit, return. No teardown half."""
1320
+ main_checkout, protected_branch, _ = content_context(CONTENT_STEP)
1321
+ claim = load_claim(args.claim_file)
1322
+ candidates, _ = content_candidates(main_checkout, CONTENT_STEP)
1323
+ claimed, dropped = verify_claim(main_checkout, claim, candidates)
1324
+ tree = stage_content(main_checkout, claimed)
1325
+ verify_staged_tree(main_checkout, tree, claimed)
1326
+ hits = content_secret_gate(main_checkout, tree, args.allow_matches)
1327
+ reference, warnings = content_pr_reference(args.anchor, args.body_file)
1328
+ branch = content_branch_name(main_checkout, args.slug, args.type)
1329
+ warnings += check_content_collision(main_checkout, branch)
1330
+ commit = cut_content_branch(main_checkout, branch, tree, args.message, claimed)
1331
+ returned = return_to_protected(main_checkout, protected_branch, branch, commit)
1332
+ return {
1333
+ "branch": branch,
1334
+ "commit": commit,
1335
+ "returned_to": returned,
1336
+ "claimed": [entry.path for entry in claimed],
1337
+ "dropped": dropped,
1338
+ "allowed_matches": bool(hits),
1339
+ "pr_reference": reference,
1340
+ "warnings": warnings,
1341
+ "next": f"open and merge the PR with `land --branch {branch}` — it finds no "
1342
+ "worktree and tears nothing down",
1343
+ }
1344
+
1345
+
1346
+ # ---------- branch deletion authority ----------
1347
+
1348
+ BRANCH_PR_RECORD = "pr-record"
1349
+ BRANCH_ANCESTRY = "ancestry"
1350
+ BRANCH_RETAINED = "retained"
1351
+
1352
+
1353
+ class BranchAuthority(NamedTuple):
1354
+ """What this repository and the platform allow for exactly this branch tip."""
1355
+
1356
+ branch: str
1357
+ decision: str
1358
+ tip: str
1359
+ reason: str
1360
+ pr: str | None = None
1361
+ degraded: bool = False
1362
+
1363
+ def as_report(self) -> dict:
1364
+ return {
1365
+ "decision": self.decision,
1366
+ "pr": self.pr,
1367
+ "tip": self.tip,
1368
+ "reason": self.reason,
1369
+ "degraded": self.degraded,
1370
+ }
1371
+
1372
+
1373
+ def branch_tip(main_tree: str, branch: str) -> str:
1374
+ """The branch's current tip OID, or "" when the branch does not exist."""
1375
+ result = git(
1376
+ ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}^{{commit}}"],
1377
+ cwd=main_tree,
1378
+ )
1379
+ return result.stdout.strip() if result.returncode == 0 else ""
1380
+
1381
+
1382
+ def refresh_integration_branch(main_tree: str, integration: str) -> str:
1383
+ """Fetch the configured branch: a stale remote-tracking ref makes ancestry lie.
1384
+
1385
+ Ancestry is read against a *freshly fetched* protected branch, and a fetch
1386
+ that fails stops rather than guesses.
1387
+ """
1388
+ tracking = f"refs/remotes/origin/{integration}"
1389
+ result = git(
1390
+ ["fetch", "origin", f"+refs/heads/{integration}:{tracking}"], cwd=main_tree
1391
+ )
1392
+ if result.returncode != 0:
1393
+ raise Stop(
1394
+ "5 branch-authority",
1395
+ f"cannot fetch {integration} — a stale ancestry check stops rather than guesses",
1396
+ (result.stderr or result.stdout).strip()[-1000:],
1397
+ )
1398
+ return tracking
1399
+
1400
+
1401
+ def platform_json(command_runner, command: list[str]):
1402
+ """Read one read-only platform command's JSON; (None, why) when it cannot be."""
1403
+ try:
1404
+ result = command_runner(command)
1405
+ except OSError as error: # the platform CLI is not installed at all
1406
+ return None, sanitize_external_detail(str(error))
1407
+ if result.returncode != 0:
1408
+ return None, sanitize_external_detail(result.stderr or result.stdout)
1409
+ try:
1410
+ return json.loads(result.stdout), ""
1411
+ except json.JSONDecodeError as error:
1412
+ return None, sanitize_external_detail(str(error))
1413
+
1414
+
1415
+ def platform_repository(command_runner) -> tuple[str, str]:
1416
+ payload, error = platform_json(
1417
+ command_runner, ["gh", "repo", "view", "--json", "nameWithOwner"]
1418
+ )
1419
+ name = payload.get("nameWithOwner") if isinstance(payload, dict) else None
1420
+ if not isinstance(name, str) or "/" not in name:
1421
+ return "", error or "the platform did not name this repository"
1422
+ return name, ""
1423
+
1424
+
1425
+ def head_pull_requests(repository: str, branch: str, command_runner):
1426
+ """Every pull request ever opened from this head ref, in any state.
1427
+
1428
+ The historical query survives deleting the branch on the remote *and*
1429
+ locally, which is exactly the moment a landing needs it — measured against
1430
+ this platform, not assumed.
1431
+ """
1432
+ owner = repository.split("/", 1)[0]
1433
+ head = quote(f"{owner}:{branch}", safe=":")
1434
+ path = f"repos/{repository}/pulls?state=all&head={head}&per_page=100"
1435
+ records, error = platform_json(command_runner, ["gh", "api", path])
1436
+ if not isinstance(records, list):
1437
+ return None, error or "the platform returned no pull request list"
1438
+ return records, ""
1439
+
1440
+
1441
+ def pull_request_by_number(repository: str, number: str, command_runner):
1442
+ """The one pull request `--pr` names — selected for the check, not exempt."""
1443
+ record, error = platform_json(
1444
+ command_runner, ["gh", "api", f"repos/{repository}/pulls/{quote(number, safe='')}"]
1445
+ )
1446
+ if not isinstance(record, dict):
1447
+ return None, error or f"pull request #{number} cannot be read"
1448
+ return record, ""
1449
+
1450
+
1451
+ def _full_name(node) -> str:
1452
+ return str(node.get("full_name") or "") if isinstance(node, dict) else ""
1453
+
1454
+
1455
+ def is_merged_record(record: dict) -> bool:
1456
+ """Merged state is `merged_at`, never `merged`.
1457
+
1458
+ The list endpoint sends `merged: null` even for a genuinely merged pull
1459
+ request — measured on a live merged pull request — so reading `merged`
1460
+ there would classify every merged PR as unmerged and silently retain
1461
+ every branch.
1462
+ """
1463
+ return bool(record.get("merged_at"))
1464
+
1465
+
1466
+ def matches_pr_tuple(record: dict, *, repository, branch, integration, tip) -> bool:
1467
+ """The full pull-request tuple — the head SHA carries the uniqueness.
1468
+
1469
+ A reused head ref resolves to several pull requests, so the ref never
1470
+ establishes uniqueness on its own.
1471
+ """
1472
+ head = record.get("head") or {}
1473
+ base = record.get("base") or {}
1474
+ return (
1475
+ _full_name(base.get("repo")) == repository
1476
+ and _full_name(head.get("repo")) == repository
1477
+ and str(head.get("ref") or "") == branch
1478
+ and str(base.get("ref") or "") == integration
1479
+ and is_merged_record(record)
1480
+ and str(head.get("sha") or "") == tip
1481
+ )
1482
+
1483
+
1484
+ def open_pull_requests_on_head(records, *, repository: str, branch: str) -> list:
1485
+ open_prs = []
1486
+ for record in records:
1487
+ head = record.get("head") or {}
1488
+ if (
1489
+ str(record.get("state") or "").lower() == "open"
1490
+ and str(head.get("ref") or "") == branch
1491
+ and _full_name(head.get("repo")) == repository
1492
+ ):
1493
+ open_prs.append(record)
1494
+ return open_prs
1495
+
1496
+
1497
+ def _pr_numbers(records) -> str:
1498
+ return ", ".join(f"#{record.get('number')}" for record in records)
1499
+
1500
+
1501
+ def _ancestry_only(branch: str, tip: str, integration: str, error: str) -> BranchAuthority:
1502
+ """Honest degradation: say that the platform was unreachable, never imply it agreed."""
1503
+ detail = f" ({error})" if error else ""
1504
+ return BranchAuthority(
1505
+ branch,
1506
+ BRANCH_RETAINED,
1507
+ tip,
1508
+ f"no platform access, so authority degrades to ancestry only — and {branch} "
1509
+ f"is not merged into {integration}{detail}",
1510
+ degraded=True,
1511
+ )
1512
+
1513
+
1514
+ def authorize_branch_deletion(
1515
+ main_tree: str,
1516
+ branch: str,
1517
+ *,
1518
+ integration: str,
1519
+ pr: str | None = None,
1520
+ command_runner=None,
1521
+ ) -> BranchAuthority:
1522
+ """Decide what this branch's own state and the platform record allow.
1523
+
1524
+ Ancestry against the freshly fetched integration branch deletes normally.
1525
+ Otherwise exactly one pull request matching the full tuple — this
1526
+ repository as base repo, the head repository equal to it, this head ref,
1527
+ the configured base ref, merged, head SHA equal to the branch tip —
1528
+ authorizes force deletion. Zero matches, several matches, an open pull
1529
+ request on the same head, or no platform access keep the branch.
1530
+ """
1531
+ command_runner = run if command_runner is None else command_runner
1532
+ tracking = refresh_integration_branch(main_tree, integration)
1533
+ tip = branch_tip(main_tree, branch)
1534
+ if not tip:
1535
+ return BranchAuthority(
1536
+ branch, BRANCH_RETAINED, "", "the local branch is already absent"
1537
+ )
1538
+ if git(["merge-base", "--is-ancestor", tip, tracking], cwd=main_tree).returncode == 0:
1539
+ return BranchAuthority(
1540
+ branch, BRANCH_ANCESTRY, tip,
1541
+ f"merged into the freshly fetched {integration}",
1542
+ )
1543
+ repository, error = platform_repository(command_runner)
1544
+ if not repository:
1545
+ return _ancestry_only(branch, tip, integration, error)
1546
+ records, error = head_pull_requests(repository, branch, command_runner)
1547
+ if records is None:
1548
+ return _ancestry_only(branch, tip, integration, error)
1549
+ open_prs = open_pull_requests_on_head(records, repository=repository, branch=branch)
1550
+ if open_prs:
1551
+ return BranchAuthority(
1552
+ branch, BRANCH_RETAINED, tip,
1553
+ f"an open pull request shares this head: {_pr_numbers(open_prs)}",
1554
+ )
1555
+ if pr is not None:
1556
+ record, error = pull_request_by_number(repository, pr, command_runner)
1557
+ if record is None:
1558
+ return BranchAuthority(branch, BRANCH_RETAINED, tip, error)
1559
+ records = [record]
1560
+ matching = [
1561
+ record for record in records
1562
+ if matches_pr_tuple(
1563
+ record, repository=repository, branch=branch,
1564
+ integration=integration, tip=tip,
1565
+ )
1566
+ ]
1567
+ if len(matching) == 1:
1568
+ return BranchAuthority(
1569
+ branch, BRANCH_PR_RECORD, tip,
1570
+ f"merged pull request #{matching[0].get('number')} matches this tip",
1571
+ pr=str(matching[0].get("number")),
1572
+ )
1573
+ if not matching:
1574
+ return BranchAuthority(
1575
+ branch, BRANCH_RETAINED, tip,
1576
+ f"no merged pull request in {repository} matches this branch tip "
1577
+ f"{tip[:7]} on {branch}",
1578
+ )
1579
+ return BranchAuthority(
1580
+ branch, BRANCH_RETAINED, tip,
1581
+ f"several merged pull requests match this tip ({_pr_numbers(matching)}) — "
1582
+ "ambiguous; name the one that authorizes deletion with --pr <number>",
1583
+ )
1584
+
1585
+
1586
+ def delete_authorized_branch(main_tree: str, authority: BranchAuthority) -> tuple[bool, str]:
1587
+ """Delete exactly what the authority cleared, re-reading the tip last.
1588
+
1589
+ The window between reading the platform record and deleting the branch is
1590
+ the whole point of the re-read: a tip that moved inside it keeps the branch.
1591
+ Ancestry deletes with git's own `-d` safety semantics; only the PR record
1592
+ authorizes the force flag.
1593
+ """
1594
+ if authority.decision == BRANCH_RETAINED:
1595
+ return False, authority.reason
1596
+ current = branch_tip(main_tree, authority.branch)
1597
+ if current != authority.tip:
1598
+ return False, (
1599
+ "the branch tip moved between authorization and deletion "
1600
+ f"({authority.tip[:7]} → {current[:7] or 'absent'})"
1601
+ )
1602
+ forced = authority.decision == BRANCH_PR_RECORD
1603
+ result = git(["branch", "-D" if forced else "-d", authority.branch], cwd=main_tree)
1604
+ if result.returncode != 0:
1605
+ return False, (result.stderr or result.stdout).strip()[:200]
1606
+ return True, authority.reason
1607
+
1608
+
1609
+ def retire_local_branch(
1610
+ branch: str,
1611
+ main_tree: str,
1612
+ integration: str,
1613
+ report: dict,
1614
+ *,
1615
+ pr: str | None = None,
1616
+ ) -> None:
1617
+ """Fast-forward the integration branch, then retire the branch its authority
1618
+ clears. Already absent is a completed step, not a failure."""
973
1619
  git(["fetch", "origin", "--prune"], cwd=main_tree)
974
- git(["checkout", "main"], cwd=main_tree)
1620
+ git(["checkout", integration], cwd=main_tree)
975
1621
  p = git(["pull", "--ff-only"], cwd=main_tree)
976
1622
  if p.returncode != 0:
977
- raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
1623
+ raise Stop("5 integration-ff",
1624
+ f"no fast-forward possible — a diverged {integration} is an anomaly",
978
1625
  (p.stderr or p.stdout).strip()[-1000:])
979
- if git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
980
- cwd=main_tree).returncode != 0:
1626
+ if not branch_tip(main_tree, branch):
981
1627
  report["branch_retired"] = "already absent"
1628
+ report["skipped"].append("branch retire: local branch already absent")
982
1629
  return
983
1630
  if branch in worktree_map(main_tree)[1]:
984
1631
  report["branch_retired"] = "refused: still checked out"
985
1632
  report["warnings"].append(
986
- f"branch -d {branch} refused (still checked out?) — never -D"
1633
+ f"branch retire refused: {branch} is still checked out"
987
1634
  )
988
1635
  return
989
- p = git(["branch", "-d", branch], cwd=main_tree)
990
- report["branch_retired"] = p.returncode == 0
1636
+ authority = authorize_branch_deletion(
1637
+ main_tree, branch, integration=integration, pr=pr
1638
+ )
1639
+ report["branch_authority"] = authority.as_report()
1640
+ deleted, detail = delete_authorized_branch(main_tree, authority)
1641
+ report["branch_retired"] = deleted
1642
+ if not deleted:
1643
+ report["warnings"].append(f"branch {branch} retained: {detail}")
1644
+
1645
+
1646
+ def pull_request_snapshot(branch: str) -> dict | None:
1647
+ """Read the platform's PR record — the resume authority for a re-run.
1648
+
1649
+ There is no persisted attempt state. Whether the push, the PR
1650
+ and the merge already happened is answered by looking, every single time.
1651
+ """
1652
+ p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
991
1653
  if p.returncode != 0:
992
- report["warnings"].append(
993
- f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}"
994
- )
1654
+ return None
1655
+ try:
1656
+ return json.loads(p.stdout)
1657
+ except json.JSONDecodeError as error:
1658
+ raise Stop("0c pr", "invalid PR response", str(error)) from error
995
1659
 
996
1660
 
997
- def recover_canonical_cleanup(
998
- branch: str,
999
- wt: str | None,
1000
- wt_exists: bool,
1001
- main_tree: str,
1002
- ) -> dict:
1003
- """Resume post-merge teardown after canonical cleanup-policy drift.
1004
-
1005
- Canonical drift keeps the normal path fail-closed: the attempt is bound to
1006
- the policy that nominated its evidence, and that binding is never relaxed.
1007
- This route is the supported way out, not a bypass — it re-reads the merged
1008
- canonical policy, requires the branch to already be an ancestor of canonical
1009
- `origin/main`, and revalidates every frozen identity against that canonical
1010
- policy before the same shared assessment and removal primitives run. Nothing
1011
- outside the revalidated evidence can be deleted, and rerunning it after a
1012
- successful teardown is a no-op.
1661
+ def remote_branch_tip(main_tree: str, branch: str) -> str:
1662
+ result = git(["ls-remote", "--heads", "origin", branch], cwd=main_tree)
1663
+ if result.returncode != 0 or not result.stdout.strip():
1664
+ return ""
1665
+ return result.stdout.split()[0]
1666
+
1667
+
1668
+ # ---------- census freshness (session-end finding, never a gate) ----------
1669
+
1670
+ def census_status(checkout: str) -> dict:
1671
+ """Read ONE named checkout's census freshness verdict, read-only.
1672
+
1673
+ A census describes the tree it was scanned in, and the drift guard resolves
1674
+ its census root from the working directory it is called in. The checkout is
1675
+ therefore named here rather than inherited: the verdict that matters at
1676
+ session end belongs to the tree the next session starts from, and a
1677
+ worktree-green verdict must never stand in for it.
1678
+
1679
+ A checkout without the hook, or without a census, answers `no_census` — the
1680
+ kit's ordinary silent degradation. Only an unreadable answer is
1681
+ `unavailable`, which the caller reports without ever acting on it.
1013
1682
  """
1014
- report: dict = {
1015
- "recovery": "canonical-cleanup",
1016
- "branch": branch,
1017
- "warnings": [],
1683
+ hook = Path(checkout) / CENSUS_HOOK
1684
+ if not hook.is_file():
1685
+ return {"state": CENSUS_ABSENT, "reasons": []}
1686
+ completed = run([sys.executable, str(hook), "--census-status"], cwd=checkout)
1687
+ if completed.returncode != 0:
1688
+ return {"state": CENSUS_UNREADABLE,
1689
+ "reasons": [(completed.stderr or completed.stdout).strip()[-300:]]}
1690
+ try:
1691
+ verdict = json.loads(completed.stdout)
1692
+ except json.JSONDecodeError as error:
1693
+ return {"state": CENSUS_UNREADABLE, "reasons": [str(error)]}
1694
+ if not isinstance(verdict, dict):
1695
+ return {"state": CENSUS_UNREADABLE, "reasons": ["verdict is not an object"]}
1696
+ return verdict
1697
+
1698
+
1699
+ def render_census_finding(verdict: dict, checkout: str) -> str:
1700
+ """State the verdict, its cause, the checkout it describes, and the route out."""
1701
+ reasons = ", ".join(str(reason) for reason in verdict.get("reasons") or [])
1702
+ lines = [
1703
+ f"CENSUS — session-end finding ({verdict.get('state')}):",
1704
+ f" · {reasons or 'the activated census is stale'}",
1705
+ f" · evaluated checkout: {checkout}",
1706
+ f" ({CENSUS_CHECKOUT_NOTE})",
1707
+ f" · recovery: {CENSUS_RECOVERY}",
1708
+ f" · {CENSUS_NOT_A_GATE}",
1709
+ ]
1710
+ if verdict.get("override_applied"):
1711
+ lines.append(f" · {CENSUS_OVERRIDE_NOTE}")
1712
+ return "\n".join(lines)
1713
+
1714
+
1715
+ def census_tracking_issues(main_tree: str) -> tuple[list[int], str]:
1716
+ """Every OPEN issue carrying the tracking marker, plus a lookup error.
1717
+
1718
+ Identity is the marker, never the title. Closed issues are history: a
1719
+ refresh that was already resolved must not wedge the next one out of a
1720
+ tracker of its own.
1721
+ """
1722
+ listed = run(["gh", "issue", "list", "--state", "open",
1723
+ "--limit", str(CENSUS_ISSUE_LIMIT), "--json", "number,body"],
1724
+ cwd=main_tree)
1725
+ if listed.returncode != 0:
1726
+ return [], (listed.stderr or listed.stdout).strip()[-500:]
1727
+ try:
1728
+ payload = json.loads(listed.stdout)
1729
+ except json.JSONDecodeError as error:
1730
+ return [], f"unreadable issue list: {error}"
1731
+ if not isinstance(payload, list):
1732
+ return [], "unreadable issue list: not an array"
1733
+ return [
1734
+ int(issue["number"]) for issue in payload
1735
+ if marker_value(issue.get("body") or "", CENSUS_TRACKING_KIND)
1736
+ == CENSUS_TRACKING_SLUG
1737
+ ], ""
1738
+
1739
+
1740
+ def track_census_finding(main_tree: str, finding: str) -> dict:
1741
+ """Open or refresh the one tracking issue so the finding outlives the session.
1742
+
1743
+ Idempotent by identity: no open match creates one through the board command,
1744
+ exactly one match is rewritten with the current verdict, and several matches
1745
+ write nothing and name them — guessing which one is the tracker is how a
1746
+ duplicate becomes permanent.
1747
+ """
1748
+ numbers, error = census_tracking_issues(main_tree)
1749
+ if error:
1750
+ return {"action": "none", "issue": None, "ok": False, "error": error}
1751
+ if len(numbers) > 1:
1752
+ named = ", ".join(f"#{number}" for number in numbers)
1753
+ return {"action": "none", "issue": None, "ok": False,
1754
+ "error": f"several open issues carry the tracking marker: {named}"}
1755
+ body = (f"<!-- {CENSUS_TRACKING_KIND}: {CENSUS_TRACKING_SLUG} -->\n\n"
1756
+ f"{finding}\n")
1757
+ with tempfile.NamedTemporaryFile(
1758
+ "w", suffix=".md", delete=False, encoding="utf-8"
1759
+ ) as handle:
1760
+ handle.write(body)
1761
+ body_file = handle.name
1762
+ try:
1763
+ if numbers:
1764
+ written = run(["gh", "issue", "edit", str(numbers[0]),
1765
+ "--body-file", body_file], cwd=main_tree)
1766
+ return _census_tracking_result("updated", numbers[0], written)
1767
+ created = run([sys.executable, str(Path(__file__).parent / "board-sync.py"),
1768
+ "create", "--title", CENSUS_TRACKING_TITLE,
1769
+ "--body-file", body_file], cwd=main_tree)
1770
+ return _census_tracking_result(
1771
+ "created", _created_issue_number(created.stdout), created)
1772
+ finally:
1773
+ Path(body_file).unlink(missing_ok=True)
1774
+
1775
+
1776
+ def _census_tracking_result(action: str, issue, completed) -> dict:
1777
+ ok = completed.returncode == 0
1778
+ return {
1779
+ "action": action if ok else "none",
1780
+ "issue": issue,
1781
+ "ok": ok,
1782
+ "error": None if ok else (completed.stderr or completed.stdout).strip()[-500:],
1018
1783
  }
1019
- git(["fetch", "origin", "main"], cwd=main_tree, check=True)
1020
- if not wt_exists:
1021
- report["worktree_removed"] = None
1022
- report["cleanup_guard"] = None
1023
- retire_local_branch(branch, main_tree, report)
1024
- report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1025
- check=True).stdout.strip()
1026
- return report
1027
- assert wt is not None
1028
- if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
1029
- raise Stop("recover-cleanup", "worktree dirty — run `commit` first", wt)
1030
- if git(["merge-base", "--is-ancestor", branch, "origin/main"],
1031
- cwd=main_tree).returncode != 0:
1032
- raise Stop(
1033
- "recover-cleanup",
1034
- "branch is not merged into canonical origin/main canonical cleanup "
1035
- "recovery only resumes an already-merged teardown",
1036
- branch,
1037
- )
1038
- evidence = canonical_recovery_evidence(wt, main_tree)
1039
- assessment = ensure_worktree_removable(
1040
- wt,
1041
- main_tree,
1042
- verified_scratch_evidence=evidence,
1043
- profile_loader=load_canonical_recovery_profile,
1044
- )
1045
- report["cleanup_guard"] = {
1046
- "assumptions_read": bool(assessment.assumptions),
1047
- "landing_generated_files": [item["path"] for item in evidence],
1784
+
1785
+
1786
+ def _created_issue_number(stdout: str):
1787
+ """Read the number back out of the board command's own `#<n> <url>` line."""
1788
+ for line in stdout.splitlines():
1789
+ head = line.strip().split(" ", 1)[0]
1790
+ if head.startswith("#") and head[1:].isdigit():
1791
+ return int(head[1:])
1792
+ return None
1793
+
1794
+
1795
+ def census_step(main_tree: str, profile: dict, report: dict) -> None:
1796
+ """Give the freshness verdict a home at session end.
1797
+
1798
+ `current` and `no_census` leave no trace at all — the same silent
1799
+ degradation the rest of the kit practises. Only `refresh_required` speaks,
1800
+ and it speaks as a finding: the landing has already happened and topology
1801
+ drift is repo-wide, so blocking here would punish an unrelated change.
1802
+ """
1803
+ verdict = census_status(main_tree)
1804
+ state = verdict.get("state")
1805
+ if state == CENSUS_UNREADABLE:
1806
+ detail = "; ".join(str(reason) for reason in verdict.get("reasons") or [])
1807
+ report["warnings"].append(
1808
+ f"census status unreadable for {main_tree}: {detail}")
1809
+ return
1810
+ if state != CENSUS_STALE:
1811
+ return
1812
+ finding = {
1813
+ "state": state,
1814
+ "evaluated_checkout": main_tree,
1815
+ "reasons": list(verdict.get("reasons") or []),
1816
+ "blocking": False,
1817
+ "finding": render_census_finding(verdict, main_tree),
1048
1818
  }
1049
- report["killed_processes"] = kill_worktree_processes(wt)
1050
- remove_verified_worktree_scratch(
1051
- wt,
1052
- main_tree,
1053
- assessment,
1054
- verified_scratch_evidence=evidence,
1055
- profile_loader=load_canonical_recovery_profile,
1056
- )
1057
- p = git(["worktree", "remove", wt], cwd=main_tree)
1058
- if p.returncode != 0:
1059
- raise Stop("recover-cleanup", "git worktree remove refused — no --force; "
1060
- "check for surviving processes (lsof/pgrep)",
1061
- (p.stderr or p.stdout).strip()[-1000:])
1062
- git(["worktree", "prune"], cwd=main_tree)
1063
- report["worktree_removed"] = wt
1064
- retire_local_branch(branch, main_tree, report)
1065
- report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1066
- check=True).stdout.strip()
1067
- return report
1819
+ report["census"] = finding
1820
+ if profile.get("wrapup", {}).get("censusTrackingIssue"):
1821
+ finding["tracking"] = track_census_finding(main_tree, finding["finding"])
1068
1822
 
1069
1823
 
1070
1824
  def cmd_land(args) -> dict:
1071
- report: dict = {"stops": [], "warnings": []}
1825
+ report: dict = {"stops": [], "warnings": [], "skipped": []}
1072
1826
  main_tree, branches = worktree_map()
1073
1827
  here = os.path.realpath(os.getcwd())
1074
1828
  if here != os.path.realpath(main_tree):
@@ -1076,48 +1830,21 @@ def cmd_land(args) -> dict:
1076
1830
  f"cwd={here} main={main_tree} — the in-worktree shell would survive "
1077
1831
  "teardown and the process-kill step")
1078
1832
  branch = args.branch
1833
+ integration, protected = branch_policy(main_tree)
1834
+ if branch in protected:
1835
+ raise Stop("land", f"{branch} is a protected branch",
1836
+ "/wrapup lands a slice branch, never the integration branch")
1079
1837
  wt = branches.get(branch)
1080
1838
  wt_exists = wt is not None and Path(wt).is_dir()
1081
- if getattr(args, "recover_canonical_cleanup", False):
1082
- return recover_canonical_cleanup(branch, wt, wt_exists, main_tree)
1083
1839
  profile = load_profile()
1084
1840
  default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
1085
1841
 
1086
1842
  # drift markers from the build-time log — mechanical, no gate
1087
1843
  markers: list[dict] = []
1088
- landing_attempt: dict | None = None
1089
- generated_evidence: tuple[dict, ...] = ()
1090
1844
  if wt_exists:
1845
+ require_landable_head("land", wt)
1091
1846
  if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
1092
1847
  raise Stop("land", "worktree dirty — run `commit` first", wt)
1093
- if getattr(args, "abandon_unfinished_attempt", False):
1094
- return {
1095
- "landing_attempt_abandoned": abandon_unfinished_landing_attempt(
1096
- wt, main_tree
1097
- ),
1098
- "files_removed": [],
1099
- "next": (
1100
- "current files remain protected; classify or move blockers, "
1101
- "then rerun land"
1102
- ),
1103
- }
1104
- landing_attempt = landing_start_artifact_inventory(wt, main_tree)
1105
- if landing_attempt["state"] == "started" and not landing_attempt["newAttempt"]:
1106
- raise Stop(
1107
- "cleanup",
1108
- "unfinished landing generator attempt has no frozen output evidence; "
1109
- f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
1110
- )
1111
- # Retained structural backstop: a journal on the active contract always
1112
- # carries an empty `generatedFiles`, because a landing-start blocker is
1113
- # refused before the journal is written. A non-empty list therefore means
1114
- # tampered or migrated evidence — never a path this run may claim.
1115
- if landing_attempt["generatedFiles"]:
1116
- raise Stop(
1117
- "cleanup",
1118
- "landing-start generated paths are consumer-owned and protected: "
1119
- + ", ".join(landing_attempt["generatedFiles"]),
1120
- )
1121
1848
  annahmen = Path(wt) / "ANNAHMEN.md"
1122
1849
  if annahmen.is_file():
1123
1850
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -1127,82 +1854,82 @@ def cmd_land(args) -> dict:
1127
1854
  "\n".join(malformed))
1128
1855
  report["drift_markers"] = markers
1129
1856
 
1130
- # Step 0b — push
1131
- if wt_exists:
1132
- assert landing_attempt is not None
1133
- if landing_attempt["state"] == "frozen" and landing_attempt["pushSucceeded"]:
1134
- generated_evidence = freeze_landing_artifact_evidence(
1135
- wt, main_tree, push_succeeded=True
1136
- )
1857
+ # Step 0c-aread the PR record first: a merged PR means push, body and
1858
+ # merge are done, and this run resumes at teardown.
1859
+ snapshot = pull_request_snapshot(branch)
1860
+ already_merged = snapshot is not None and snapshot.get("state") == "MERGED"
1861
+
1862
+ # Step 0b — push, unless the remote already carries this commit
1863
+ if wt_exists and already_merged:
1864
+ report["skipped"].append("push: the PR for this branch is already merged")
1865
+ elif wt_exists:
1866
+ local_tip = git(["rev-parse", "HEAD"], cwd=wt, check=True).stdout.strip()
1867
+ if remote_branch_tip(main_tree, branch) == local_tip:
1868
+ report["skipped"].append(f"push: origin/{branch} already at {local_tip[:7]}")
1137
1869
  else:
1138
- if landing_attempt["state"] == "frozen":
1139
- # Validate every previously frozen identity before a retry can
1140
- # invoke the generator-capable pre-push hook.
1141
- reopen_frozen_landing_attempt(wt, main_tree)
1142
1870
  p = git(["push", "-u", "origin", branch], cwd=wt)
1143
- generated_evidence = freeze_landing_artifact_evidence(
1144
- wt, main_tree, push_succeeded=p.returncode == 0
1145
- )
1146
1871
  if p.returncode != 0:
1147
- raise Stop(
1148
- "0b push", "push rejected",
1149
- (p.stderr or p.stdout).strip()[-2000:],
1150
- )
1872
+ raise Stop("0b push", "push rejected",
1873
+ (p.stderr or p.stdout).strip()[-2000:])
1151
1874
 
1152
1875
  # Step 0c — ensure PR + final body
1153
- p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
1154
1876
  pr_body = ""
1155
- if p.returncode == 0:
1156
- d = json.loads(p.stdout)
1157
- pr, pr_state, pr_body = str(d["number"]), d["state"], d.get("body") or ""
1877
+ if snapshot is not None:
1878
+ pr, pr_body = str(snapshot["number"]), snapshot.get("body") or ""
1158
1879
  report["pr_reused"] = True
1159
1880
  elif args.body_file:
1160
1881
  if not args.title:
1161
1882
  raise Stop("0c pr", "--title required to create a PR")
1162
- run(["gh", "pr", "create", "--base", "main", "--head", branch,
1883
+ run(["gh", "pr", "create", "--base", integration, "--head", branch,
1163
1884
  "--title", args.title, "--body-file", args.body_file], check=True)
1164
- d = json.loads(run(["gh", "pr", "view", branch, "--json", "number,state,body"],
1165
- check=True).stdout)
1166
- pr, pr_state, pr_body = str(d["number"]), d["state"], d.get("body") or ""
1885
+ created = pull_request_snapshot(branch)
1886
+ if created is None:
1887
+ raise Stop("0c pr", "PR was created but cannot be read back")
1888
+ pr, pr_body = str(created["number"]), created.get("body") or ""
1167
1889
  report["pr_reused"] = False
1168
1890
  else:
1169
1891
  raise Stop("0c pr", "no open PR and no --body-file to create one")
1170
1892
  report["pr"] = pr
1171
1893
 
1172
- if args.body_file and report["pr_reused"]:
1173
- pr_body = Path(args.body_file).read_text()
1174
- final = merge_markers_into_body(pr_body, markers)
1175
- tmp = Path(args.body_file).with_suffix(".final.md")
1176
- tmp.write_text(final)
1177
- run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
1178
- elif markers:
1179
- final = merge_markers_into_body(pr_body, markers)
1180
- if final != pr_body:
1181
- tmp = Path(f"/tmp/wrapup-body-{pr}.md")
1894
+ if already_merged:
1895
+ report["skipped"].append("PR body + body-check: the PR is already merged")
1896
+ else:
1897
+ if args.body_file and report["pr_reused"]:
1898
+ pr_body = Path(args.body_file).read_text()
1899
+ final = merge_markers_into_body(pr_body, markers)
1900
+ tmp = Path(args.body_file).with_suffix(".final.md")
1182
1901
  tmp.write_text(final)
1183
1902
  run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
1184
-
1185
- # body-convention check: closes-vs-Part-of + **Retro:** line
1186
- p = run([sys.executable, str(Path(__file__).parent / "pr-body-check.py"),
1187
- "--branch", branch])
1188
- report["body_check_exit"] = p.returncode
1189
- if p.returncode == 1:
1190
- raise Stop("0c body-check", "pr-body-check exit 1 — fix the body, never merge red",
1191
- (p.stdout + p.stderr).strip()[-2000:])
1192
- if p.returncode == 2:
1193
- report["warnings"].append("pr-body-check exit 2 (fail-open): "
1194
- + (p.stdout + p.stderr).strip()[:300])
1195
-
1196
- # merge gatewait boundedly for fresh-PR checks; already-MERGED resumes
1197
- # directly at teardown. Progress stays on stderr so stdout remains one JSON.
1198
- already_merged = wait_for_merge_gate(pr)
1903
+ elif markers:
1904
+ final = merge_markers_into_body(pr_body, markers)
1905
+ if final != pr_body:
1906
+ tmp = Path(f"/tmp/wrapup-body-{pr}.md")
1907
+ tmp.write_text(final)
1908
+ run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
1909
+
1910
+ # body-convention check: closes-vs-Part-of + **Retro:** line
1911
+ p = run([sys.executable, str(Path(__file__).parent / "pr-body-check.py"),
1912
+ "--branch", branch])
1913
+ report["body_check_exit"] = p.returncode
1914
+ if p.returncode == 1:
1915
+ raise Stop("0c body-check", "pr-body-check exit 1 fix the body, never merge red",
1916
+ (p.stdout + p.stderr).strip()[-2000:])
1917
+ if p.returncode == 2:
1918
+ report["warnings"].append("pr-body-check exit 2 (fail-open): "
1919
+ + (p.stdout + p.stderr).strip()[:300])
1920
+
1921
+ # merge gate — wait boundedly for fresh-PR checks; already-MERGED resumes
1922
+ # directly at teardown. Progress stays on stderr so stdout remains one JSON.
1923
+ already_merged = wait_for_merge_gate(pr)
1199
1924
 
1200
1925
  # Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
1201
1926
  # PR state is the authority, not gh's exit code: `--delete-branch` also tries
1202
1927
  # to delete the LOCAL branch, which fails while a worktree holds it — the
1203
1928
  # remote merge is through anyway (dogfood run PR).
1204
1929
  merge_err = ""
1205
- if not already_merged:
1930
+ if already_merged:
1931
+ report["skipped"].append("merge: the PR is already MERGED")
1932
+ else:
1206
1933
  p = run(["gh", "pr", "merge", pr, "--merge", "--delete-branch"], cwd=main_tree)
1207
1934
  if p.returncode != 0:
1208
1935
  merge_err = (p.stderr or p.stdout).strip()[-2000:]
@@ -1215,27 +1942,23 @@ def cmd_land(args) -> dict:
1215
1942
  report["warnings"].append(f"gh pr merge exited non-zero but PR is MERGED: {merge_err[:300]}")
1216
1943
  report["merged"] = True
1217
1944
 
1218
- # Step 2 — kill the worktree's dev server, then Step 4 — teardown
1219
- if wt_exists:
1220
- git(["fetch", "origin", "main"], cwd=main_tree, check=True)
1221
- generated = tuple(item["path"] for item in generated_evidence)
1222
- cleanup = ensure_worktree_removable(
1223
- wt,
1224
- main_tree,
1225
- verified_scratch_files=generated,
1226
- verified_scratch_evidence=generated_evidence,
1227
- )
1228
- report["cleanup_guard"] = {
1229
- "assumptions_read": bool(cleanup.assumptions),
1230
- "landing_generated_files": list(generated),
1231
- }
1945
+ # Step 2 — quiesce this worktree's own dev servers, then Step 4 — teardown.
1946
+ # Teardown always runs: a direct /wrapup invocation is its authorization,
1947
+ # and the classifier's four rules are the only protection.
1948
+ if not wt_exists:
1949
+ report["skipped"].append("teardown: the worktree is already removed")
1950
+ else:
1951
+ git(["fetch", "origin", integration], cwd=main_tree, check=True)
1232
1952
  report["killed_processes"] = kill_worktree_processes(wt)
1233
- remove_verified_worktree_scratch(
1234
- wt,
1235
- main_tree,
1236
- cleanup,
1237
- verified_scratch_evidence=generated_evidence,
1238
- )
1953
+ assessment = assess_teardown(wt, main_tree)
1954
+ rendered = teardown_report(assessment)
1955
+ report["teardown"] = {
1956
+ "assumptions_read": (Path(wt) / "ANNAHMEN.md").is_file(),
1957
+ "report": rendered,
1958
+ }
1959
+ if not assessment.removable:
1960
+ raise Stop("4 teardown", "teardown is blocked", rendered)
1961
+ report["scratch_removed"] = remove_teardown_scratch(assessment)
1239
1962
  p = git(["worktree", "remove", wt], cwd=main_tree)
1240
1963
  if p.returncode != 0:
1241
1964
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -1243,19 +1966,9 @@ def cmd_land(args) -> dict:
1243
1966
  (p.stderr or p.stdout).strip()[-1000:])
1244
1967
  git(["worktree", "prune"], cwd=main_tree)
1245
1968
  report["worktree_removed"] = wt
1246
- git(["fetch", "origin", "--prune"], cwd=main_tree)
1247
1969
 
1248
- # Step 5 — main ff + local branch delete (-d only, after the pull)
1249
- git(["checkout", "main"], cwd=main_tree)
1250
- p = git(["pull", "--ff-only"], cwd=main_tree)
1251
- if p.returncode != 0:
1252
- raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
1253
- (p.stderr or p.stdout).strip()[-1000:])
1254
- p = git(["branch", "-d", branch], cwd=main_tree)
1255
- if p.returncode != 0 and branch in worktree_map()[1]:
1256
- report["warnings"].append(f"branch -d {branch} refused (still checked out?) — never -D")
1257
- elif p.returncode != 0:
1258
- report["warnings"].append(f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}")
1970
+ # Step 5 — integration ff + branch retirement by authority (after the pull)
1971
+ retire_local_branch(branch, main_tree, integration, report, pr=args.pr)
1259
1972
 
1260
1973
  # Step 5b — verify declared auto-closes (backtick-swallowed `closes`
1261
1974
  # misses). Targets come from the merged PR body's close keywords, never
@@ -1278,11 +1991,11 @@ def cmd_land(args) -> dict:
1278
1991
  states.append(f"#{t} already closed")
1279
1992
  report["issue_close"] = " · ".join(states)
1280
1993
 
1281
- # Step 5c — local merged-branch sweep (-d only: unreachable-from-main is refused)
1994
+ # Step 5c — local merged-branch sweep (-d only: unreachable-from-integration is refused)
1282
1995
  swept = []
1283
- p = git(["branch", "--merged", "main", "--format=%(refname:short)"], cwd=main_tree)
1996
+ p = git(["branch", "--merged", integration, "--format=%(refname:short)"], cwd=main_tree)
1284
1997
  for b in p.stdout.split():
1285
- if b in ("main", branch) or not b:
1998
+ if b in protected or b == branch or not b:
1286
1999
  continue
1287
2000
  if git(["branch", "-d", b], cwd=main_tree).returncode == 0:
1288
2001
  swept.append(b)
@@ -1300,7 +2013,7 @@ def cmd_land(args) -> dict:
1300
2013
  for line in git(["ls-remote", "--heads", "origin"], cwd=main_tree, check=True).stdout.splitlines():
1301
2014
  if "refs/heads/" in line:
1302
2015
  remote_heads.add(line.split("refs/heads/", 1)[1].strip())
1303
- stale = stale_remote_set(merged_heads, remote_heads, open_heads)
2016
+ stale = stale_remote_set(merged_heads, remote_heads, open_heads, set(protected))
1304
2017
  if not profile.get("wrapup", {}).get("remoteBranchSweep"):
1305
2018
  report["swept_remote"] = {"enabled": False, "stale_count": len(stale)}
1306
2019
  elif stale:
@@ -1311,14 +2024,19 @@ def cmd_land(args) -> dict:
1311
2024
  else:
1312
2025
  report["swept_remote"] = {"enabled": True, "deleted": []}
1313
2026
 
1314
- # Step 5e.1 — anchor tracker sync + completeness; 5e.3 land sanity
2027
+ # Step 5e.1 — anchor tracker sync + completeness; 5e.3 land sanity.
2028
+ # The anchor is optional: a branch that carries no issue number lands
2029
+ # without one instead of being refused.
1315
2030
  anchor = args.anchor
1316
2031
  if not anchor and issue:
1317
2032
  p = run([sys.executable, str(Path(__file__).parent / "board-sync.py"),
1318
2033
  "parent-of", issue])
1319
2034
  out = p.stdout.strip()
1320
2035
  anchor = out if p.returncode == 0 and out.isdigit() else None
1321
- if anchor:
2036
+ if not anchor:
2037
+ report["anchor_sync"] = {"anchor": None,
2038
+ "skipped": "branch carries no issue anchor"}
2039
+ else:
1322
2040
  bs = str(Path(__file__).parent / "board-sync.py")
1323
2041
  dry = run([sys.executable, bs, "anchor-sync", str(anchor), "--dry-run"])
1324
2042
  wet = run([sys.executable, bs, "anchor-sync", str(anchor)])
@@ -1354,12 +2072,29 @@ def cmd_land(args) -> dict:
1354
2072
  else (pwet.stderr or pwet.stdout).strip()[-1000:],
1355
2073
  }
1356
2074
 
2075
+ # Step 5f — census freshness. The verdict is read for the main checkout,
2076
+ # because that is the tree the next session starts from; a stale one becomes
2077
+ # a named finding (and optionally durable work) instead of dying with this
2078
+ # session. It is a diagnostic, so nothing it can do may stop a landing that
2079
+ # already merged — hence the blanket catch.
2080
+ try:
2081
+ census_step(main_tree, profile, report)
2082
+ except Exception as error: # noqa: BLE001 — a diagnostic never gates a landing
2083
+ report["warnings"].append(f"census step skipped: {error}")
2084
+
1357
2085
  report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1358
2086
  check=True).stdout.strip()
1359
2087
  return report
1360
2088
 
1361
2089
 
1362
- def main() -> int:
2090
+ def pull_request_number(value: str) -> str:
2091
+ """`--pr` names which pull request is checked — it never skips the check."""
2092
+ if not value.isdigit():
2093
+ raise argparse.ArgumentTypeError("--pr expects a pull request number")
2094
+ return value
2095
+
2096
+
2097
+ def build_parser() -> argparse.ArgumentParser:
1363
2098
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
1364
2099
  sub = ap.add_subparsers(dest="cmd", required=True)
1365
2100
  sub.add_parser("preflight", help="read-only context report (run in the worktree)")
@@ -1372,29 +2107,41 @@ def main() -> int:
1372
2107
  l.add_argument("--title", help="PR title (create path)")
1373
2108
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
1374
2109
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
2110
+ l.add_argument("--pr", type=pull_request_number,
2111
+ help="the PR that authorizes deleting this branch when a reused "
2112
+ "head ref makes the record ambiguous (still validated "
2113
+ "against the full tuple)")
1375
2114
  l.add_argument("--skip-malformed-drift", action="store_true")
1376
- recovery = l.add_mutually_exclusive_group()
1377
- recovery.add_argument(
1378
- ABANDON_ATTEMPT_FLAG,
1379
- action="store_true",
1380
- help=(
1381
- "archive an interrupted pre-freeze landing attempt without deleting "
1382
- "or claiming its ambiguous files"
1383
- ),
1384
- )
1385
- recovery.add_argument(
1386
- RECOVER_CANONICAL_CLEANUP_FLAG,
1387
- action="store_true",
1388
- help=(
1389
- "after canonical cleanup-policy drift, revalidate the frozen landing "
1390
- "evidence against canonical origin/main and resume the merged "
1391
- "worktree's teardown idempotently"
1392
- ),
1393
- )
1394
- args = ap.parse_args()
2115
+ # No recovery flag exists: an interrupted landing is resumed by re-running
2116
+ # `land`, which re-checks present state at every step.
2117
+ sub.add_parser("content-claim",
2118
+ help="read-only: infer durable content in the main checkout")
2119
+ c2 = sub.add_parser("content-commit",
2120
+ help="land a confirmed content claim (run in the main checkout)")
2121
+ c2.add_argument("--claim-file", required=True,
2122
+ help="the confirmed claim: the content-claim records the user picked")
2123
+ c2.add_argument("-m", "--message", required=True)
2124
+ c2.add_argument("--slug", required=True, help="branch slug for the content branch")
2125
+ c2.add_argument("--type", required=True, help="branch type for the content branch")
2126
+ c2.add_argument("--anchor", help="anchor issue # the content belongs to")
2127
+ c2.add_argument("--body-file", help="PR body to check (a close keyword is refused)")
2128
+ c2.add_argument("--allow-matches", action="store_true",
2129
+ help="proceed despite secret-grep matches (after human review)")
2130
+ return ap
2131
+
1395
2132
 
2133
+ def main() -> int:
2134
+ args = build_parser().parse_args()
2135
+
2136
+ handlers = {
2137
+ "preflight": cmd_preflight,
2138
+ "commit": cmd_commit,
2139
+ "land": cmd_land,
2140
+ "content-claim": cmd_content_claim,
2141
+ "content-commit": cmd_content_commit,
2142
+ }
1396
2143
  try:
1397
- result = {"preflight": cmd_preflight, "commit": cmd_commit, "land": cmd_land}[args.cmd](args)
2144
+ result = handlers[args.cmd](args)
1398
2145
  print(json.dumps({"ok": True, "cmd": args.cmd, **result}, ensure_ascii=False, indent=2))
1399
2146
  return 0
1400
2147
  except Stop as s: