@ikon85/agent-workflow-kit 0.36.5 → 0.38.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 (46) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +20 -12
  2. package/.agents/skills/setup-workflow/SKILL.md +24 -3
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
  5. package/.agents/skills/wrapup/SKILL.md +42 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
  8. package/.claude/skills/setup-workflow/SKILL.md +24 -3
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +33 -10
  13. package/README.md +88 -1
  14. package/agent-workflow-kit.package.json +36 -28
  15. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  16. package/docs/agents/workflow-capabilities.json +11 -0
  17. package/package.json +1 -1
  18. package/scripts/anchor_table.py +14 -8
  19. package/scripts/project-skill-extension.mjs +21 -2
  20. package/scripts/readiness.mjs +32 -4
  21. package/scripts/release-state.mjs +19 -9
  22. package/scripts/release-state.test.mjs +71 -8
  23. package/scripts/test_anchor_table.py +69 -0
  24. package/scripts/test_board_sync_create_idempotency.py +15 -2
  25. package/scripts/test_board_sync_wave_title.py +14 -2
  26. package/scripts/test_census_backstop.py +33 -0
  27. package/scripts/test_orchestrate_wave_contract.py +44 -0
  28. package/scripts/test_retro_wrapup_contract.py +19 -2
  29. package/scripts/test_worktree_wrapup_contract.py +1004 -3
  30. package/scripts/test_wrapup_land.py +428 -0
  31. package/scripts/workflow-advisories/core.py +44 -2
  32. package/scripts/worktree-lifecycle/README.md +126 -4
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +143 -5
  35. package/scripts/worktree-lifecycle/core.py +1383 -20
  36. package/scripts/worktree-lifecycle/profile.py +40 -3
  37. package/scripts/worktree-lifecycle/session.py +1857 -0
  38. package/scripts/worktree-lifecycle/setup.py +15 -0
  39. package/scripts/wrapup-land.py +650 -27
  40. package/src/cli.mjs +60 -27
  41. package/src/lib/bundle.mjs +1 -0
  42. package/src/lib/manifest.mjs +173 -3
  43. package/src/lib/projectSkillExtension.mjs +78 -1
  44. package/src/lib/updateCandidate.mjs +3 -1
  45. package/src/lib/updateDecisions.mjs +2 -2
  46. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
@@ -26,7 +26,9 @@ import os
26
26
  import re
27
27
  import subprocess
28
28
  import sys
29
+ import time
29
30
  from pathlib import Path
31
+ from urllib.parse import quote
30
32
 
31
33
  sys.path.insert(0, str(Path(__file__).resolve().parent))
32
34
 
@@ -41,7 +43,32 @@ ISSUE_BRANCH_RE = re.compile(r"^(feat|fix|chore|docs)/(\d+)-")
41
43
  DRIFT_LINE_RE = re.compile(r"^-\s*#(\d+)(?:\s*§([^:]+?))?\s*:\s*(.+)$")
42
44
  RETRO_LINE_RE = re.compile(r"^\*\*Retro:\*\*", re.MULTILINE)
43
45
  DRIFT_MARKER_RE = re.compile(r"<!--\s*annahme-drift:\s*(\{.*?\})\s*-->")
44
- RED_CHECK_CONCLUSIONS = {"FAILURE", "CANCELLED", "TIMED_OUT"}
46
+ RED_CHECK_CONCLUSIONS = {
47
+ "ACTION_REQUIRED",
48
+ "CANCELLED",
49
+ "FAILURE",
50
+ "STALE",
51
+ "STARTUP_FAILURE",
52
+ "TIMED_OUT",
53
+ }
54
+ GREEN_CHECK_CONCLUSIONS = {"NEUTRAL", "SKIPPED", "SUCCESS"}
55
+ CHECK_WAIT_SECONDS = 20 * 60
56
+ CHECK_POLL_SECONDS = 10
57
+ MAX_EXTERNAL_DETAIL = 500
58
+ INFRA_FAILURE_RE = re.compile(
59
+ r"(?:"
60
+ r"account payments? (?:have )?failed|"
61
+ r"billing issue|"
62
+ r"spending limit|"
63
+ r"no hosted parallelism|"
64
+ r"no (?:hosted )?runners? (?:are )?available|"
65
+ r"no runner matching|"
66
+ r"runner (?:is )?unavailable|"
67
+ r"failed to (?:acquire|assign) (?:a )?job|"
68
+ r"job was not started"
69
+ r")",
70
+ re.IGNORECASE,
71
+ )
45
72
 
46
73
 
47
74
  class Stop(Exception):
@@ -62,6 +89,311 @@ def git(args: list[str], cwd: str | None = None, check: bool = False) -> subproc
62
89
  return run(["git", *args], cwd=cwd, check=check)
63
90
 
64
91
 
92
+ # ---------- PR check gate ----------
93
+
94
+ def check_name(check: dict) -> str:
95
+ return str(check.get("name") or check.get("context") or "?")
96
+
97
+
98
+ def check_conclusion(check: dict) -> str:
99
+ # CheckRun entries carry `conclusion: null` while pending. Do not let a
100
+ # simultaneously present legacy `state` field turn that explicit null green.
101
+ value = check.get("conclusion") if "conclusion" in check else check.get("state")
102
+ return str(value or "").upper()
103
+
104
+
105
+ def pending_checks(checks: list[dict]) -> list[dict]:
106
+ pending = []
107
+ for check in checks:
108
+ conclusion = check_conclusion(check)
109
+ status = str(check.get("status") or "").upper()
110
+ if conclusion in RED_CHECK_CONCLUSIONS | GREEN_CHECK_CONCLUSIONS:
111
+ continue
112
+ if conclusion in {"EXPECTED", "IN_PROGRESS", "PENDING", "QUEUED", "REQUESTED", "WAITING"}:
113
+ pending.append(check)
114
+ continue
115
+ if not conclusion or status not in {"", "COMPLETED"}:
116
+ pending.append(check)
117
+ return pending
118
+
119
+
120
+ def red_checks(checks: list[dict]) -> list[dict]:
121
+ return [
122
+ check for check in checks
123
+ if check_conclusion(check) in RED_CHECK_CONCLUSIONS | {"ERROR"}
124
+ ]
125
+
126
+
127
+ def sanitize_external_detail(text: str) -> str:
128
+ compact = re.sub(r"[\x00-\x1f\x7f]+", " ", text)
129
+ return re.sub(r"\s+", " ", compact).strip()[:MAX_EXTERNAL_DETAIL]
130
+
131
+
132
+ def _check_text(check: dict) -> str:
133
+ values = []
134
+ for key in ("name", "context", "description", "title", "summary", "text"):
135
+ value = check.get(key)
136
+ if isinstance(value, str):
137
+ values.append(value)
138
+ return " ".join(values)
139
+
140
+
141
+ def _matching_actions_job(check: dict, jobs: list[dict]) -> dict | None:
142
+ """Return the one Actions job represented by this check, never a run sibling."""
143
+ candidates = [job for job in jobs if isinstance(job, dict)]
144
+ details_url = check.get("link") or check.get("detailsUrl") or check.get("targetUrl")
145
+ job_match = (
146
+ re.search(r"/job/(\d+)(?:/|$)", details_url)
147
+ if isinstance(details_url, str)
148
+ else None
149
+ )
150
+ if job_match is not None:
151
+ job_id = job_match.group(1)
152
+ matching_ids = [
153
+ job for job in candidates
154
+ if str(job.get("databaseId") or "") == job_id
155
+ or re.search(
156
+ rf"/job/{re.escape(job_id)}(?:/|$)",
157
+ str(job.get("url") or ""),
158
+ )
159
+ ]
160
+ return matching_ids[0] if len(matching_ids) == 1 else None
161
+
162
+ name = check_name(check)
163
+ matching_names = [
164
+ job for job in candidates if str(job.get("name") or "") == name
165
+ ]
166
+ return matching_names[0] if len(matching_names) == 1 else None
167
+
168
+
169
+ def _job_database_id(job: dict) -> str:
170
+ database_id = job.get("databaseId")
171
+ if database_id is not None:
172
+ return str(database_id)
173
+ match = re.search(r"/job/(\d+)(?:/|$)", str(job.get("url") or ""))
174
+ return match.group(1) if match is not None else ""
175
+
176
+
177
+ def infrastructure_failure_diagnosis(
178
+ check: dict,
179
+ *,
180
+ command_runner=run,
181
+ ) -> str:
182
+ """Classify known Actions billing/runner failures with bounded read-only detail."""
183
+ if INFRA_FAILURE_RE.search(_check_text(check)):
184
+ return "infrastructure failure (billing or runner unavailable)"
185
+
186
+ details_url = check.get("link") or check.get("detailsUrl") or check.get("targetUrl")
187
+ run_match = (
188
+ re.search(r"/actions/runs/(\d+)(?:/|$)", details_url)
189
+ if isinstance(details_url, str)
190
+ else None
191
+ )
192
+ if run_match is None:
193
+ return ""
194
+ run_id = run_match.group(1)
195
+
196
+ jobs_result = command_runner(
197
+ ["gh", "run", "view", run_id, "--json", "jobs"]
198
+ )
199
+ matching_job = None
200
+ if jobs_result.returncode == 0:
201
+ try:
202
+ jobs = json.loads(jobs_result.stdout).get("jobs") or []
203
+ except (AttributeError, json.JSONDecodeError):
204
+ jobs = []
205
+ matching_job = _matching_actions_job(check, jobs)
206
+ zero_step_failure = (
207
+ matching_job is not None
208
+ and str(matching_job.get("conclusion") or "").lower()
209
+ in {"failure", "cancelled", "timed_out", "startup_failure"}
210
+ and not (matching_job.get("steps") or [])
211
+ )
212
+ if zero_step_failure:
213
+ return "infrastructure failure (failed Actions job had zero steps)"
214
+
215
+ job_id = _job_database_id(matching_job) if matching_job is not None else ""
216
+ if not job_id:
217
+ return ""
218
+ log_result = command_runner([
219
+ "gh", "run", "view", run_id, "--job", job_id, "--log-failed"
220
+ ])
221
+ external = sanitize_external_detail(
222
+ (log_result.stdout or "") + " " + (log_result.stderr or "")
223
+ )
224
+ if INFRA_FAILURE_RE.search(external):
225
+ return "infrastructure failure (billing or runner unavailable)"
226
+ return ""
227
+
228
+
229
+ def _pr_gate_snapshot(pr: str, command_runner) -> dict:
230
+ result = command_runner([
231
+ "gh", "pr", "view", pr, "--json",
232
+ "state,mergeable,mergeStateStatus,statusCheckRollup,baseRefName",
233
+ ])
234
+ if result.returncode != 0:
235
+ raise Stop(
236
+ "0c merge-gate",
237
+ "cannot inspect PR checks",
238
+ sanitize_external_detail(result.stderr or result.stdout),
239
+ )
240
+ try:
241
+ return json.loads(result.stdout)
242
+ except json.JSONDecodeError as error:
243
+ raise Stop("0c merge-gate", "invalid PR check response", str(error)) from error
244
+
245
+
246
+ def _required_checks_snapshot(pr: str, command_runner) -> list[dict]:
247
+ result = command_runner([
248
+ "gh", "pr", "checks", pr, "--required", "--json",
249
+ "name,state,link,bucket,workflow",
250
+ ])
251
+ # gh uses 1 for failed checks and 8 for pending checks. Both still carry
252
+ # the authoritative JSON needed by this gate.
253
+ if result.returncode not in {0, 1, 8}:
254
+ raise Stop(
255
+ "0c merge-gate",
256
+ "cannot inspect required PR checks",
257
+ sanitize_external_detail(result.stderr or result.stdout),
258
+ )
259
+ try:
260
+ checks = json.loads(result.stdout)
261
+ except json.JSONDecodeError as error:
262
+ raise Stop(
263
+ "0c merge-gate", "invalid required PR check response", str(error)
264
+ ) from error
265
+ if not isinstance(checks, list) or not all(
266
+ isinstance(check, dict) for check in checks
267
+ ):
268
+ raise Stop(
269
+ "0c merge-gate",
270
+ "invalid required PR check response",
271
+ "expected a JSON array of checks",
272
+ )
273
+ return checks
274
+
275
+
276
+ def _configured_required_check_names(snapshot: dict, command_runner) -> set[str]:
277
+ branch = snapshot.get("baseRefName")
278
+ if not isinstance(branch, str) or not branch:
279
+ raise Stop("0c merge-gate", "PR response has no base branch")
280
+ repo_result = command_runner(["gh", "repo", "view", "--json", "nameWithOwner"])
281
+ if repo_result.returncode != 0:
282
+ raise Stop(
283
+ "0c merge-gate",
284
+ "cannot inspect repository identity",
285
+ sanitize_external_detail(repo_result.stderr or repo_result.stdout),
286
+ )
287
+ try:
288
+ repository = json.loads(repo_result.stdout)["nameWithOwner"]
289
+ except (KeyError, TypeError, json.JSONDecodeError) as error:
290
+ raise Stop("0c merge-gate", "invalid repository identity response", str(error)) from error
291
+ rules_result = command_runner([
292
+ "gh", "api",
293
+ f"repos/{quote(repository, safe='/')}/rules/branches/{quote(branch, safe='')}",
294
+ ])
295
+ if rules_result.returncode != 0:
296
+ raise Stop(
297
+ "0c merge-gate",
298
+ "cannot inspect required-check rules",
299
+ sanitize_external_detail(rules_result.stderr or rules_result.stdout),
300
+ )
301
+ try:
302
+ rules = json.loads(rules_result.stdout)
303
+ except json.JSONDecodeError as error:
304
+ raise Stop("0c merge-gate", "invalid required-check rules response", str(error)) from error
305
+ if not isinstance(rules, list):
306
+ raise Stop("0c merge-gate", "invalid required-check rules response", "expected a JSON array")
307
+ return {
308
+ check["context"]
309
+ for rule in rules
310
+ if isinstance(rule, dict) and rule.get("type") == "required_status_checks"
311
+ for check in (rule.get("parameters", {}).get("required_status_checks") or [])
312
+ if isinstance(check, dict) and isinstance(check.get("context"), str)
313
+ }
314
+
315
+
316
+ def _failed_check_detail(failed: list[dict], command_runner) -> str:
317
+ details = []
318
+ for check in failed:
319
+ diagnosis = infrastructure_failure_diagnosis(
320
+ check, command_runner=command_runner
321
+ )
322
+ suffix = f" — {diagnosis}" if diagnosis else ""
323
+ details.append(f"{check_name(check)}{suffix}")
324
+ return ", ".join(details)
325
+
326
+
327
+ def _waiting_checks(
328
+ required_checks: list[dict],
329
+ configured_required_names: set[str],
330
+ ) -> list[dict]:
331
+ waiting = pending_checks(required_checks)
332
+ observed = {check_name(check) for check in required_checks}
333
+ waiting.extend(
334
+ {"name": f"{name} (awaiting discovery)"}
335
+ for name in sorted(configured_required_names - observed)
336
+ )
337
+ return waiting
338
+
339
+
340
+ def wait_for_merge_gate(
341
+ pr: str,
342
+ *,
343
+ timeout_seconds: float = CHECK_WAIT_SECONDS,
344
+ poll_interval: float = CHECK_POLL_SECONDS,
345
+ command_runner=run,
346
+ clock=time.monotonic,
347
+ sleeper=time.sleep,
348
+ progress_stream=sys.stderr,
349
+ configured_required_names: set[str] | None = None,
350
+ ) -> bool:
351
+ """Wait for pending checks. Return True when the PR was already merged."""
352
+ started = clock()
353
+ while True:
354
+ snapshot = _pr_gate_snapshot(pr, command_runner)
355
+ state = snapshot.get("state")
356
+ if state == "MERGED":
357
+ return True
358
+ if state != "OPEN":
359
+ raise Stop("0c merge-gate", f"PR state {state} — cannot merge")
360
+ if snapshot.get("mergeable") == "CONFLICTING":
361
+ raise Stop("0c merge-gate", "PR is CONFLICTING — rebase/resolve the branch")
362
+
363
+ if configured_required_names is None:
364
+ configured_required_names = _configured_required_check_names(
365
+ snapshot, command_runner
366
+ )
367
+ checks = _required_checks_snapshot(pr, command_runner)
368
+ failed = red_checks(checks)
369
+ if failed:
370
+ raise Stop(
371
+ "0c merge-gate",
372
+ "red checks on the PR",
373
+ _failed_check_detail(failed, command_runner),
374
+ )
375
+
376
+ waiting = _waiting_checks(checks, configured_required_names)
377
+ if not waiting:
378
+ return False
379
+
380
+ elapsed = clock() - started
381
+ names = ", ".join(check_name(check) for check in waiting)
382
+ if elapsed >= timeout_seconds:
383
+ raise Stop(
384
+ "0c merge-gate",
385
+ "check wait budget exceeded",
386
+ f"elapsed={elapsed:.1f}s; still pending: {names}",
387
+ )
388
+ print(
389
+ f"wrapup: waiting for PR #{pr} checks "
390
+ f"({elapsed:.1f}s elapsed): {names}",
391
+ file=progress_stream,
392
+ flush=True,
393
+ )
394
+ sleeper(min(poll_interval, timeout_seconds - elapsed))
395
+
396
+
65
397
  # ---------- context ----------
66
398
 
67
399
  def worktree_map(cwd: str | None = None) -> tuple[str, dict[str, str]]:
@@ -105,18 +437,189 @@ def load_worktree_cleanup_core():
105
437
  return module
106
438
 
107
439
 
108
- def ensure_worktree_removable(wt: str, main_tree: str):
109
- profile_path = Path(main_tree) / "docs/agents/workflow-capabilities.json"
110
- if not profile_path.is_file():
111
- return None
440
+ def load_candidate_landing_profile(core, wt: str):
441
+ """Let the committed worktree policy nominate evidence without deletion."""
442
+ result = core.run(
443
+ [
444
+ "git", "show",
445
+ "HEAD:docs/agents/workflow-capabilities.json",
446
+ ],
447
+ cwd=Path(wt),
448
+ check=False,
449
+ )
450
+ if result.returncode != 0:
451
+ detail = (result.stderr or result.stdout).strip()
452
+ raise core.LifecycleError(
453
+ f"committed landing profile cannot be read from worktree HEAD: {detail}"
454
+ )
455
+ candidate = core.load_profile_text(result.stdout)
456
+ if not candidate.landing_generated_artifact_policy_configured:
457
+ raise core.LifecycleError(
458
+ "worktree landing artifact policy is not configured; "
459
+ "run setup-workflow and commit the explicit policy decision first"
460
+ )
461
+ return candidate
462
+
463
+
464
+ def load_canonical_landing_profile(core, wt: str, main_tree: str):
465
+ """Authorize deletion only after the candidate policy is canonical."""
466
+ candidate = load_candidate_landing_profile(core, wt)
467
+ result = core.run(
468
+ [
469
+ "git", "show",
470
+ "origin/main:docs/agents/workflow-capabilities.json",
471
+ ],
472
+ cwd=Path(main_tree),
473
+ check=False,
474
+ )
475
+ if result.returncode != 0:
476
+ detail = (result.stderr or result.stdout).strip()
477
+ raise core.LifecycleError(
478
+ f"canonical landing profile cannot be read from origin/main: {detail}"
479
+ )
480
+ canonical = core.load_profile_text(result.stdout)
481
+ if not canonical.landing_generated_artifact_policy_configured:
482
+ raise core.LifecycleError(
483
+ "merged canonical landing artifact policy is not configured"
484
+ )
485
+ if (
486
+ candidate.landing_generated_artifact_patterns
487
+ != canonical.landing_generated_artifact_patterns
488
+ or candidate.scratch_patterns != canonical.scratch_patterns
489
+ ):
490
+ raise core.LifecycleError(
491
+ "worktree cleanup policy differs from merged canonical origin/main"
492
+ )
493
+ attempt_path = core.artifact_baseline_path(Path(wt)).with_name(
494
+ core.LANDING_ATTEMPT_FILE
495
+ )
496
+ if not os.path.lexists(attempt_path):
497
+ raise core.LifecycleError(
498
+ "landing attempt is missing before canonical cleanup authorization"
499
+ )
500
+ attempt = core.landing_start_artifact_inventory(candidate, Path(wt))
501
+ if (
502
+ attempt["policyDigest"]
503
+ != core.landing_cleanup_policy_digest(canonical)
504
+ ):
505
+ raise core.LifecycleError(
506
+ "landing attempt cleanup policy differs from merged canonical origin/main"
507
+ )
508
+ return canonical
509
+
510
+
511
+ def landing_artifact_baseline_digest(wt: str, main_tree: str) -> str:
112
512
  core = load_worktree_cleanup_core()
113
513
  try:
114
- profile = core.load_profile(profile_path)
115
- assessment = core.cleanup_assessment(
514
+ load_candidate_landing_profile(core, wt)
515
+ return core.load_artifact_baseline(Path(wt)).digest
516
+ except core.LifecycleError as error:
517
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
518
+
519
+
520
+ def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
521
+ core = load_worktree_cleanup_core()
522
+ try:
523
+ profile = load_candidate_landing_profile(core, wt)
524
+ return core.landing_start_artifact_inventory(profile, Path(wt))
525
+ except core.LifecycleError as error:
526
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
527
+
528
+
529
+ def landing_verified_scratch_evidence(
530
+ wt: str,
531
+ main_tree: str,
532
+ *,
533
+ expected_baseline_digest: str | None = None,
534
+ landing_start_files: tuple[str, ...] = (),
535
+ ) -> tuple[dict, ...]:
536
+ core = load_worktree_cleanup_core()
537
+ try:
538
+ profile = load_candidate_landing_profile(core, wt)
539
+ return core.verified_landing_scratch_evidence(
116
540
  profile,
117
- Path(main_tree),
118
541
  Path(wt),
119
- merge_target="origin/main",
542
+ expected_baseline_digest=expected_baseline_digest,
543
+ landing_start_files=landing_start_files,
544
+ )
545
+ except core.LifecycleError as error:
546
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
547
+
548
+
549
+ def freeze_landing_artifact_evidence(
550
+ wt: str,
551
+ main_tree: str,
552
+ *,
553
+ push_succeeded: bool,
554
+ ) -> tuple[dict, ...]:
555
+ core = load_worktree_cleanup_core()
556
+ try:
557
+ profile = load_candidate_landing_profile(core, wt)
558
+ return core.freeze_landing_artifact_evidence(
559
+ profile,
560
+ Path(wt),
561
+ push_succeeded=push_succeeded,
562
+ )
563
+ except core.LifecycleError as error:
564
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
565
+
566
+
567
+ def reopen_frozen_landing_attempt(wt: str, main_tree: str) -> tuple[dict, ...]:
568
+ core = load_worktree_cleanup_core()
569
+ try:
570
+ profile = load_candidate_landing_profile(core, wt)
571
+ return core.reopen_frozen_landing_attempt(profile, Path(wt))
572
+ except core.LifecycleError as error:
573
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
574
+
575
+
576
+ def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
577
+ core = load_worktree_cleanup_core()
578
+ try:
579
+ return str(core.abandon_unfinished_landing_attempt(Path(wt)))
580
+ except core.LifecycleError as error:
581
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
582
+
583
+
584
+ def landing_verified_scratch_files(
585
+ wt: str,
586
+ main_tree: str,
587
+ *,
588
+ expected_baseline_digest: str | None = None,
589
+ ) -> tuple[str, ...]:
590
+ core = load_worktree_cleanup_core()
591
+ try:
592
+ profile = load_candidate_landing_profile(core, wt)
593
+ return core.verified_landing_scratch_files(
594
+ profile,
595
+ Path(wt),
596
+ expected_baseline_digest=expected_baseline_digest,
597
+ )
598
+ except core.LifecycleError as error:
599
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
600
+
601
+
602
+ def ensure_worktree_removable(
603
+ wt: str,
604
+ main_tree: str,
605
+ *,
606
+ verified_scratch_files: tuple[str, ...] = (),
607
+ verified_scratch_evidence: tuple[dict, ...] = (),
608
+ ):
609
+ core = load_worktree_cleanup_core()
610
+ try:
611
+ profile = load_canonical_landing_profile(core, wt, main_tree)
612
+ kwargs = {"merge_target": "origin/main"}
613
+ paths = (
614
+ tuple(item["path"] for item in verified_scratch_evidence)
615
+ or verified_scratch_files
616
+ )
617
+ if paths:
618
+ kwargs["verified_scratch_files"] = paths
619
+ if verified_scratch_evidence:
620
+ kwargs["verified_scratch_evidence"] = verified_scratch_evidence
621
+ assessment = core.cleanup_assessment(
622
+ profile, Path(main_tree), Path(wt), **kwargs
120
623
  )
121
624
  except core.LifecycleError as error:
122
625
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
@@ -129,6 +632,69 @@ def ensure_worktree_removable(wt: str, main_tree: str):
129
632
  return assessment
130
633
 
131
634
 
635
+ def remove_verified_worktree_scratch(
636
+ wt: str,
637
+ main_tree: str,
638
+ assessment,
639
+ *,
640
+ verified_scratch_files: tuple[str, ...] = (),
641
+ verified_scratch_evidence: tuple[dict, ...] = (),
642
+ ):
643
+ """Re-verify and delete only assessed regular scratch files."""
644
+ core = load_worktree_cleanup_core()
645
+ try:
646
+ profile = load_canonical_landing_profile(core, wt, main_tree)
647
+ evidence = verified_scratch_evidence
648
+ paths = tuple(item["path"] for item in evidence) or verified_scratch_files
649
+ latest = core.cleanup_assessment(
650
+ profile,
651
+ Path(main_tree),
652
+ Path(wt),
653
+ merge_target="origin/main",
654
+ verified_scratch_files=paths,
655
+ verified_scratch_evidence=evidence,
656
+ )
657
+ if latest.reasons:
658
+ raise core.LifecycleError(
659
+ "cleanup changed before removal: " + "; ".join(latest.reasons)
660
+ )
661
+ if (
662
+ latest.branch != assessment.branch
663
+ or latest.scratch_files != assessment.scratch_files
664
+ or latest.assumptions != assessment.assumptions
665
+ or latest.root_device != assessment.root_device
666
+ or latest.root_inode != assessment.root_inode
667
+ or latest.scratch_evidence != assessment.scratch_evidence
668
+ ):
669
+ raise core.LifecycleError(
670
+ "cleanup changed before removal: inventory no longer matches preview"
671
+ )
672
+ with core.verified_worktree_root(
673
+ latest.worktree,
674
+ latest.root_device,
675
+ latest.root_inode,
676
+ ) as root_descriptor:
677
+ core.remove_authorized_scratch(
678
+ profile,
679
+ root_descriptor,
680
+ latest.scratch_files,
681
+ latest.scratch_evidence,
682
+ )
683
+ final = core.cleanup_assessment(
684
+ profile,
685
+ Path(main_tree),
686
+ Path(wt),
687
+ merge_target="origin/main",
688
+ )
689
+ if final.reasons:
690
+ raise core.LifecycleError(
691
+ "cleanup changed after scratch removal: " + "; ".join(final.reasons)
692
+ )
693
+ return final
694
+ except core.LifecycleError as error:
695
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
696
+
697
+
132
698
  # ---------- drift log (ANNAHMEN.md) ----------
133
699
 
134
700
  def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
@@ -370,9 +936,39 @@ def cmd_land(args) -> dict:
370
936
 
371
937
  # drift markers from the build-time log — mechanical, no gate
372
938
  markers: list[dict] = []
939
+ artifact_baseline_digest: str | None = None
940
+ landing_start_files: tuple[str, ...] = ()
941
+ landing_attempt: dict | None = None
942
+ generated_evidence: tuple[dict, ...] = ()
373
943
  if wt_exists:
374
944
  if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
375
945
  raise Stop("land", "worktree dirty — run `commit` first", wt)
946
+ if getattr(args, "abandon_unfinished_attempt", False):
947
+ return {
948
+ "landing_attempt_abandoned": abandon_unfinished_landing_attempt(
949
+ wt, main_tree
950
+ ),
951
+ "files_removed": [],
952
+ "next": (
953
+ "current files remain protected; classify or move blockers, "
954
+ "then rerun land"
955
+ ),
956
+ }
957
+ landing_attempt = landing_start_artifact_inventory(wt, main_tree)
958
+ if landing_attempt["state"] == "started" and not landing_attempt["newAttempt"]:
959
+ raise Stop(
960
+ "cleanup",
961
+ "unfinished landing generator attempt has no frozen output evidence; "
962
+ "classify its files, then rerun with --abandon-unfinished-attempt",
963
+ )
964
+ landing_start_files = tuple(landing_attempt["generatedFiles"])
965
+ if landing_start_files:
966
+ raise Stop(
967
+ "cleanup",
968
+ "landing-start generated paths are consumer-owned and protected: "
969
+ + ", ".join(landing_start_files),
970
+ )
971
+ artifact_baseline_digest = landing_attempt["baselineDigest"]
376
972
  annahmen = Path(wt) / "ANNAHMEN.md"
377
973
  if annahmen.is_file():
378
974
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -384,9 +980,25 @@ def cmd_land(args) -> dict:
384
980
 
385
981
  # Step 0b — push
386
982
  if wt_exists:
387
- p = git(["push", "-u", "origin", branch], cwd=wt)
388
- if p.returncode != 0:
389
- raise Stop("0b push", "push rejected", (p.stderr or p.stdout).strip()[-2000:])
983
+ assert landing_attempt is not None
984
+ if landing_attempt["state"] == "frozen" and landing_attempt["pushSucceeded"]:
985
+ generated_evidence = freeze_landing_artifact_evidence(
986
+ wt, main_tree, push_succeeded=True
987
+ )
988
+ else:
989
+ if landing_attempt["state"] == "frozen":
990
+ # Validate every previously frozen identity before a retry can
991
+ # invoke the generator-capable pre-push hook.
992
+ reopen_frozen_landing_attempt(wt, main_tree)
993
+ p = git(["push", "-u", "origin", branch], cwd=wt)
994
+ generated_evidence = freeze_landing_artifact_evidence(
995
+ wt, main_tree, push_succeeded=p.returncode == 0
996
+ )
997
+ if p.returncode != 0:
998
+ raise Stop(
999
+ "0b push", "push rejected",
1000
+ (p.stderr or p.stdout).strip()[-2000:],
1001
+ )
390
1002
 
391
1003
  # Step 0c — ensure PR + final body
392
1004
  p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
@@ -432,20 +1044,9 @@ def cmd_land(args) -> dict:
432
1044
  report["warnings"].append("pr-body-check exit 2 (fail-open): "
433
1045
  + (p.stdout + p.stderr).strip()[:300])
434
1046
 
435
- # merge gate
436
- d = json.loads(run(["gh", "pr", "view", pr, "--json",
437
- "state,mergeable,mergeStateStatus,statusCheckRollup"],
438
- check=True).stdout)
439
- red = [c for c in (d.get("statusCheckRollup") or [])
440
- if (c.get("conclusion") or "").upper() in RED_CHECK_CONCLUSIONS]
441
- if red:
442
- raise Stop("0c merge-gate", "red checks on the PR",
443
- ", ".join(c.get("name") or c.get("context", "?") for c in red))
444
- if d.get("mergeable") == "CONFLICTING":
445
- raise Stop("0c merge-gate", "PR is CONFLICTING — rebase/resolve the branch")
446
- already_merged = d.get("state") == "MERGED"
447
- if d.get("state") not in ("OPEN", "MERGED"):
448
- raise Stop("0c merge-gate", f"PR state {d.get('state')} — cannot merge")
1047
+ # merge gate — wait boundedly for fresh-PR checks; already-MERGED resumes
1048
+ # directly at teardown. Progress stays on stderr so stdout remains one JSON.
1049
+ already_merged = wait_for_merge_gate(pr)
449
1050
 
450
1051
  # Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
451
1052
  # PR state is the authority, not gh's exit code: `--delete-branch` also tries
@@ -468,12 +1069,26 @@ def cmd_land(args) -> dict:
468
1069
  # Step 2 — kill the worktree's dev server, then Step 4 — teardown
469
1070
  if wt_exists:
470
1071
  git(["fetch", "origin", "main"], cwd=main_tree, check=True)
471
- cleanup = ensure_worktree_removable(wt, main_tree)
1072
+ generated = tuple(item["path"] for item in generated_evidence)
1073
+ cleanup = ensure_worktree_removable(
1074
+ wt,
1075
+ main_tree,
1076
+ verified_scratch_files=generated,
1077
+ verified_scratch_evidence=generated_evidence,
1078
+ )
472
1079
  report["cleanup_guard"] = {
473
1080
  "active": cleanup is not None,
474
1081
  "assumptions_read": bool(cleanup and cleanup.assumptions),
1082
+ "landing_generated_files": list(generated),
475
1083
  }
476
1084
  report["killed_processes"] = kill_worktree_processes(wt)
1085
+ if cleanup is not None:
1086
+ cleanup = remove_verified_worktree_scratch(
1087
+ wt,
1088
+ main_tree,
1089
+ cleanup,
1090
+ verified_scratch_evidence=generated_evidence,
1091
+ )
477
1092
  p = git(["worktree", "remove", wt], cwd=main_tree)
478
1093
  if p.returncode != 0:
479
1094
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -601,6 +1216,14 @@ def main() -> int:
601
1216
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
602
1217
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
603
1218
  l.add_argument("--skip-malformed-drift", action="store_true")
1219
+ l.add_argument(
1220
+ "--abandon-unfinished-attempt",
1221
+ action="store_true",
1222
+ help=(
1223
+ "archive an interrupted pre-freeze landing attempt without deleting "
1224
+ "or claiming its ambiguous files"
1225
+ ),
1226
+ )
604
1227
  args = ap.parse_args()
605
1228
 
606
1229
  try: