@ikon85/agent-workflow-kit 0.37.0 → 0.39.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 (44) hide show
  1. package/.agents/skills/kit-update/SKILL.md +33 -1
  2. package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
  3. package/.agents/skills/setup-workflow/SKILL.md +42 -4
  4. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  5. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  6. package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
  7. package/.agents/skills/wrapup/SKILL.md +46 -0
  8. package/.claude/hooks/drift-guard.py +212 -21
  9. package/.claude/skills/kit-update/SKILL.md +33 -1
  10. package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
  11. package/.claude/skills/setup-workflow/SKILL.md +42 -4
  12. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  13. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  14. package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
  15. package/.claude/skills/wrapup/SKILL.md +46 -0
  16. package/README.md +73 -0
  17. package/agent-workflow-kit.package.json +49 -25
  18. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  19. package/docs/agents/workflow-capabilities.json +11 -1
  20. package/package.json +1 -1
  21. package/scripts/board_bootstrap.py +367 -0
  22. package/scripts/kit-update-pr.mjs +20 -7
  23. package/scripts/kit-update-pr.test.mjs +29 -0
  24. package/scripts/profile_globs.py +347 -0
  25. package/scripts/test_board_bootstrap.py +348 -0
  26. package/scripts/test_drift_guard_diagnostics.py +295 -0
  27. package/scripts/test_orchestrate_wave_contract.py +9 -0
  28. package/scripts/test_profile_globs.py +280 -0
  29. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  30. package/scripts/test_worktree_wrapup_contract.py +1592 -3
  31. package/scripts/workflow-advisories/core.py +29 -4
  32. package/scripts/worktree-lifecycle/README.md +139 -0
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +22 -51
  35. package/scripts/worktree-lifecycle/core.py +1206 -5
  36. package/scripts/worktree-lifecycle/profile.py +38 -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 +461 -14
  40. package/src/cli.mjs +32 -1
  41. package/src/commands/update.mjs +30 -21
  42. package/src/consumer-migrations.json +19 -0
  43. package/src/lib/bundle.mjs +11 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -10,6 +10,9 @@ import zlib
10
10
  from pathlib import Path
11
11
 
12
12
  from core import (
13
+ BaselineBackfillDeferred,
14
+ capture_artifact_baseline,
15
+ ensure_artifact_baseline,
13
16
  LifecycleError,
14
17
  load_profile,
15
18
  local_branch_exists,
@@ -137,6 +140,17 @@ def create(args: argparse.Namespace) -> Path:
137
140
  ensure_reusable_base(
138
141
  main, repo=target, rev="HEAD", base=args.base, label=f"worktree {target}"
139
142
  )
143
+ try:
144
+ ensure_artifact_baseline(
145
+ target,
146
+ reject_ignored_patterns=profile.landing_generated_artifact_patterns,
147
+ )
148
+ except BaselineBackfillDeferred as error:
149
+ print(
150
+ "Baseline backfill deferred; preserve the worktree, remove generated "
151
+ f"blockers or commit/stash tracked work, then retry: {error}",
152
+ file=sys.stderr,
153
+ )
140
154
  print(f"Worktree already exists: {target} ({branch})")
141
155
  return target
142
156
 
@@ -157,6 +171,7 @@ def create(args: argparse.Namespace) -> Path:
157
171
  issue=args.issue,
158
172
  branch=branch,
159
173
  )
174
+ capture_artifact_baseline(target)
160
175
  except Exception:
161
176
  remove_failed_worktree(main, target, branch, not branch_existed)
162
177
  raise
@@ -71,6 +71,14 @@ INFRA_FAILURE_RE = re.compile(
71
71
  )
72
72
 
73
73
 
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"
79
+ )
80
+
81
+
74
82
  class Stop(Exception):
75
83
  def __init__(self, step: str, reason: str, detail: str = ""):
76
84
  super().__init__(reason)
@@ -437,18 +445,215 @@ def load_worktree_cleanup_core():
437
445
  return module
438
446
 
439
447
 
440
- def ensure_worktree_removable(wt: str, main_tree: str):
441
- profile_path = Path(main_tree) / "docs/agents/workflow-capabilities.json"
442
- if not profile_path.is_file():
443
- return None
448
+ def load_candidate_landing_profile(core, wt: str):
449
+ """Let the committed worktree policy nominate evidence without deletion."""
450
+ result = core.run(
451
+ [
452
+ "git", "show",
453
+ "HEAD:docs/agents/workflow-capabilities.json",
454
+ ],
455
+ cwd=Path(wt),
456
+ check=False,
457
+ )
458
+ if result.returncode != 0:
459
+ detail = (result.stderr or result.stdout).strip()
460
+ raise core.LifecycleError(
461
+ f"committed landing profile cannot be read from worktree HEAD: {detail}"
462
+ )
463
+ candidate = core.load_profile_text(result.stdout)
464
+ if not candidate.landing_generated_artifact_policy_configured:
465
+ raise core.LifecycleError(
466
+ "worktree landing artifact policy is not configured; "
467
+ "run setup-workflow and commit the explicit policy decision first"
468
+ )
469
+ return candidate
470
+
471
+
472
+ def load_merged_canonical_profile(core, main_tree: str):
473
+ """Read the merged canonical cleanup policy from origin/main."""
474
+ result = core.run(
475
+ [
476
+ "git", "show",
477
+ "origin/main:docs/agents/workflow-capabilities.json",
478
+ ],
479
+ cwd=Path(main_tree),
480
+ check=False,
481
+ )
482
+ if result.returncode != 0:
483
+ detail = (result.stderr or result.stdout).strip()
484
+ raise core.LifecycleError(
485
+ f"canonical landing profile cannot be read from origin/main: {detail}"
486
+ )
487
+ canonical = core.load_profile_text(result.stdout)
488
+ if not canonical.landing_generated_artifact_policy_configured:
489
+ raise core.LifecycleError(
490
+ "merged canonical landing artifact policy is not configured"
491
+ )
492
+ return canonical
493
+
494
+
495
+ def load_canonical_landing_profile(core, wt: str, main_tree: str):
496
+ """Authorize deletion only after the candidate policy is canonical."""
497
+ candidate = load_candidate_landing_profile(core, wt)
498
+ canonical = load_merged_canonical_profile(core, main_tree)
499
+ if (
500
+ candidate.landing_generated_artifact_patterns
501
+ != canonical.landing_generated_artifact_patterns
502
+ or candidate.scratch_patterns != canonical.scratch_patterns
503
+ ):
504
+ raise core.LifecycleError(
505
+ "worktree cleanup policy differs from merged canonical origin/main"
506
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
507
+ )
508
+ if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
509
+ raise core.LifecycleError(
510
+ "landing attempt is missing before canonical cleanup authorization"
511
+ )
512
+ attempt = core.landing_start_artifact_inventory(candidate, Path(wt))
513
+ if (
514
+ attempt["policyDigest"]
515
+ != core.landing_cleanup_policy_digest(canonical)
516
+ ):
517
+ raise core.LifecycleError(
518
+ "landing attempt cleanup policy differs from merged canonical origin/main"
519
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
520
+ )
521
+ return canonical
522
+
523
+
524
+ def load_canonical_recovery_profile(core, wt: str, main_tree: str):
525
+ """Authorize post-merge cleanup from canonical policy alone after drift.
526
+
527
+ The committed worktree candidate is deliberately never consulted here: after
528
+ canonical drift it is stale, and trusting it could only widen what may be
529
+ deleted. Every authority boundary re-validates the frozen evidence against
530
+ the merged canonical policy instead.
531
+ """
532
+ canonical = load_merged_canonical_profile(core, main_tree)
533
+ core.canonical_recovery_evidence(canonical, Path(wt))
534
+ return canonical
535
+
536
+
537
+ def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
444
538
  core = load_worktree_cleanup_core()
445
539
  try:
446
- profile = core.load_profile(profile_path)
447
- assessment = core.cleanup_assessment(
540
+ profile = load_candidate_landing_profile(core, wt)
541
+ return core.landing_start_artifact_inventory(profile, Path(wt))
542
+ except core.LegacyLandingAttempt as error:
543
+ # Legacy, not damage: report the classification and the safe route out
544
+ # verbatim, without the corruption framing of a guard failure.
545
+ raise Stop("cleanup", str(error)) from error
546
+ except core.LifecycleError as error:
547
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
548
+
549
+
550
+ def landing_verified_scratch_evidence(
551
+ wt: str,
552
+ main_tree: str,
553
+ *,
554
+ expected_baseline_digest: str | None = None,
555
+ landing_start_files: tuple[str, ...] = (),
556
+ ) -> tuple[dict, ...]:
557
+ core = load_worktree_cleanup_core()
558
+ try:
559
+ profile = load_candidate_landing_profile(core, wt)
560
+ return core.verified_landing_scratch_evidence(
448
561
  profile,
449
- Path(main_tree),
450
562
  Path(wt),
451
- merge_target="origin/main",
563
+ expected_baseline_digest=expected_baseline_digest,
564
+ landing_start_files=landing_start_files,
565
+ )
566
+ except core.LifecycleError as error:
567
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
568
+
569
+
570
+ def freeze_landing_artifact_evidence(
571
+ wt: str,
572
+ main_tree: str,
573
+ *,
574
+ push_succeeded: bool,
575
+ ) -> tuple[dict, ...]:
576
+ core = load_worktree_cleanup_core()
577
+ try:
578
+ profile = load_candidate_landing_profile(core, wt)
579
+ return core.freeze_landing_artifact_evidence(
580
+ profile,
581
+ Path(wt),
582
+ push_succeeded=push_succeeded,
583
+ )
584
+ except core.LifecycleError as error:
585
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
586
+
587
+
588
+ def reopen_frozen_landing_attempt(wt: str, main_tree: str) -> tuple[dict, ...]:
589
+ core = load_worktree_cleanup_core()
590
+ try:
591
+ profile = load_candidate_landing_profile(core, wt)
592
+ return core.reopen_frozen_landing_attempt(profile, Path(wt))
593
+ except core.LifecycleError as error:
594
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
595
+
596
+
597
+ def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
598
+ core = load_worktree_cleanup_core()
599
+ try:
600
+ return str(core.abandon_unfinished_landing_attempt(Path(wt)))
601
+ except core.LifecycleError as error:
602
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
603
+
604
+
605
+ def canonical_recovery_evidence(wt: str, main_tree: str) -> tuple[dict, ...]:
606
+ """Revalidate the frozen landing evidence against canonical policy alone."""
607
+ core = load_worktree_cleanup_core()
608
+ try:
609
+ canonical = load_merged_canonical_profile(core, main_tree)
610
+ return core.canonical_recovery_evidence(canonical, Path(wt))
611
+ except core.LifecycleError as error:
612
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
613
+
614
+
615
+ def landing_verified_scratch_files(
616
+ wt: str,
617
+ main_tree: str,
618
+ *,
619
+ expected_baseline_digest: str | None = None,
620
+ ) -> tuple[str, ...]:
621
+ core = load_worktree_cleanup_core()
622
+ try:
623
+ profile = load_candidate_landing_profile(core, wt)
624
+ return core.verified_landing_scratch_files(
625
+ profile,
626
+ Path(wt),
627
+ expected_baseline_digest=expected_baseline_digest,
628
+ )
629
+ except core.LifecycleError as error:
630
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
631
+
632
+
633
+ def ensure_worktree_removable(
634
+ wt: str,
635
+ main_tree: str,
636
+ *,
637
+ verified_scratch_files: tuple[str, ...] = (),
638
+ verified_scratch_evidence: tuple[dict, ...] = (),
639
+ profile_loader=None,
640
+ ):
641
+ core = load_worktree_cleanup_core()
642
+ # Resolved at call time so the module attribute stays the single seam.
643
+ loader = profile_loader or load_canonical_landing_profile
644
+ try:
645
+ profile = loader(core, wt, main_tree)
646
+ kwargs = {"merge_target": "origin/main"}
647
+ paths = (
648
+ tuple(item["path"] for item in verified_scratch_evidence)
649
+ or verified_scratch_files
650
+ )
651
+ if paths:
652
+ kwargs["verified_scratch_files"] = paths
653
+ if verified_scratch_evidence:
654
+ kwargs["verified_scratch_evidence"] = verified_scratch_evidence
655
+ assessment = core.cleanup_assessment(
656
+ profile, Path(main_tree), Path(wt), **kwargs
452
657
  )
453
658
  except core.LifecycleError as error:
454
659
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
@@ -461,6 +666,71 @@ def ensure_worktree_removable(wt: str, main_tree: str):
461
666
  return assessment
462
667
 
463
668
 
669
+ def remove_verified_worktree_scratch(
670
+ wt: str,
671
+ main_tree: str,
672
+ assessment,
673
+ *,
674
+ verified_scratch_files: tuple[str, ...] = (),
675
+ verified_scratch_evidence: tuple[dict, ...] = (),
676
+ profile_loader=None,
677
+ ):
678
+ """Re-verify and delete only assessed regular scratch files."""
679
+ core = load_worktree_cleanup_core()
680
+ loader = profile_loader or load_canonical_landing_profile
681
+ try:
682
+ profile = loader(core, wt, main_tree)
683
+ evidence = verified_scratch_evidence
684
+ paths = tuple(item["path"] for item in evidence) or verified_scratch_files
685
+ latest = core.cleanup_assessment(
686
+ profile,
687
+ Path(main_tree),
688
+ Path(wt),
689
+ merge_target="origin/main",
690
+ verified_scratch_files=paths,
691
+ verified_scratch_evidence=evidence,
692
+ )
693
+ if latest.reasons:
694
+ raise core.LifecycleError(
695
+ "cleanup changed before removal: " + "; ".join(latest.reasons)
696
+ )
697
+ if (
698
+ latest.branch != assessment.branch
699
+ or latest.scratch_files != assessment.scratch_files
700
+ or latest.assumptions != assessment.assumptions
701
+ or latest.root_device != assessment.root_device
702
+ or latest.root_inode != assessment.root_inode
703
+ or latest.scratch_evidence != assessment.scratch_evidence
704
+ ):
705
+ raise core.LifecycleError(
706
+ "cleanup changed before removal: inventory no longer matches preview"
707
+ )
708
+ with core.verified_worktree_root(
709
+ latest.worktree,
710
+ latest.root_device,
711
+ latest.root_inode,
712
+ ) as root_descriptor:
713
+ core.remove_authorized_scratch(
714
+ profile,
715
+ root_descriptor,
716
+ latest.scratch_files,
717
+ latest.scratch_evidence,
718
+ )
719
+ final = core.cleanup_assessment(
720
+ profile,
721
+ Path(main_tree),
722
+ Path(wt),
723
+ merge_target="origin/main",
724
+ )
725
+ if final.reasons:
726
+ raise core.LifecycleError(
727
+ "cleanup changed after scratch removal: " + "; ".join(final.reasons)
728
+ )
729
+ return final
730
+ except core.LifecycleError as error:
731
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
732
+
733
+
464
734
  # ---------- drift log (ANNAHMEN.md) ----------
465
735
 
466
736
  def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
@@ -686,6 +956,105 @@ def cmd_commit(args) -> dict:
686
956
  return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
687
957
 
688
958
 
959
+ def retire_local_branch(branch: str, main_tree: str, report: dict) -> None:
960
+ """Fast-forward main, then retire the merged local branch with `-d` only."""
961
+ git(["fetch", "origin", "--prune"], cwd=main_tree)
962
+ git(["checkout", "main"], cwd=main_tree)
963
+ p = git(["pull", "--ff-only"], cwd=main_tree)
964
+ if p.returncode != 0:
965
+ raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
966
+ (p.stderr or p.stdout).strip()[-1000:])
967
+ if git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
968
+ cwd=main_tree).returncode != 0:
969
+ report["branch_retired"] = "already absent"
970
+ return
971
+ if branch in worktree_map(main_tree)[1]:
972
+ report["branch_retired"] = "refused: still checked out"
973
+ report["warnings"].append(
974
+ f"branch -d {branch} refused (still checked out?) — never -D"
975
+ )
976
+ return
977
+ p = git(["branch", "-d", branch], cwd=main_tree)
978
+ report["branch_retired"] = p.returncode == 0
979
+ if p.returncode != 0:
980
+ report["warnings"].append(
981
+ f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}"
982
+ )
983
+
984
+
985
+ def recover_canonical_cleanup(
986
+ branch: str,
987
+ wt: str | None,
988
+ wt_exists: bool,
989
+ main_tree: str,
990
+ ) -> dict:
991
+ """Resume post-merge teardown after canonical cleanup-policy drift.
992
+
993
+ Canonical drift keeps the normal path fail-closed: the attempt is bound to
994
+ the policy that nominated its evidence, and that binding is never relaxed.
995
+ This route is the supported way out, not a bypass — it re-reads the merged
996
+ canonical policy, requires the branch to already be an ancestor of canonical
997
+ `origin/main`, and revalidates every frozen identity against that canonical
998
+ policy before the same shared assessment and removal primitives run. Nothing
999
+ outside the revalidated evidence can be deleted, and rerunning it after a
1000
+ successful teardown is a no-op.
1001
+ """
1002
+ report: dict = {
1003
+ "recovery": "canonical-cleanup",
1004
+ "branch": branch,
1005
+ "warnings": [],
1006
+ }
1007
+ git(["fetch", "origin", "main"], cwd=main_tree, check=True)
1008
+ if not wt_exists:
1009
+ report["worktree_removed"] = None
1010
+ report["cleanup_guard"] = None
1011
+ retire_local_branch(branch, main_tree, report)
1012
+ report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1013
+ check=True).stdout.strip()
1014
+ return report
1015
+ assert wt is not None
1016
+ if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
1017
+ raise Stop("recover-cleanup", "worktree dirty — run `commit` first", wt)
1018
+ if git(["merge-base", "--is-ancestor", branch, "origin/main"],
1019
+ cwd=main_tree).returncode != 0:
1020
+ raise Stop(
1021
+ "recover-cleanup",
1022
+ "branch is not merged into canonical origin/main — canonical cleanup "
1023
+ "recovery only resumes an already-merged teardown",
1024
+ branch,
1025
+ )
1026
+ evidence = canonical_recovery_evidence(wt, main_tree)
1027
+ assessment = ensure_worktree_removable(
1028
+ wt,
1029
+ main_tree,
1030
+ verified_scratch_evidence=evidence,
1031
+ profile_loader=load_canonical_recovery_profile,
1032
+ )
1033
+ report["cleanup_guard"] = {
1034
+ "assumptions_read": bool(assessment.assumptions),
1035
+ "landing_generated_files": [item["path"] for item in evidence],
1036
+ }
1037
+ report["killed_processes"] = kill_worktree_processes(wt)
1038
+ remove_verified_worktree_scratch(
1039
+ wt,
1040
+ main_tree,
1041
+ assessment,
1042
+ verified_scratch_evidence=evidence,
1043
+ profile_loader=load_canonical_recovery_profile,
1044
+ )
1045
+ p = git(["worktree", "remove", wt], cwd=main_tree)
1046
+ if p.returncode != 0:
1047
+ raise Stop("recover-cleanup", "git worktree remove refused — no --force; "
1048
+ "check for surviving processes (lsof/pgrep)",
1049
+ (p.stderr or p.stdout).strip()[-1000:])
1050
+ git(["worktree", "prune"], cwd=main_tree)
1051
+ report["worktree_removed"] = wt
1052
+ retire_local_branch(branch, main_tree, report)
1053
+ report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1054
+ check=True).stdout.strip()
1055
+ return report
1056
+
1057
+
689
1058
  def cmd_land(args) -> dict:
690
1059
  report: dict = {"stops": [], "warnings": []}
691
1060
  main_tree, branches = worktree_map()
@@ -697,14 +1066,46 @@ def cmd_land(args) -> dict:
697
1066
  branch = args.branch
698
1067
  wt = branches.get(branch)
699
1068
  wt_exists = wt is not None and Path(wt).is_dir()
1069
+ if getattr(args, "recover_canonical_cleanup", False):
1070
+ return recover_canonical_cleanup(branch, wt, wt_exists, main_tree)
700
1071
  profile = load_profile()
701
1072
  default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
702
1073
 
703
1074
  # drift markers from the build-time log — mechanical, no gate
704
1075
  markers: list[dict] = []
1076
+ landing_attempt: dict | None = None
1077
+ generated_evidence: tuple[dict, ...] = ()
705
1078
  if wt_exists:
706
1079
  if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
707
1080
  raise Stop("land", "worktree dirty — run `commit` first", wt)
1081
+ if getattr(args, "abandon_unfinished_attempt", False):
1082
+ return {
1083
+ "landing_attempt_abandoned": abandon_unfinished_landing_attempt(
1084
+ wt, main_tree
1085
+ ),
1086
+ "files_removed": [],
1087
+ "next": (
1088
+ "current files remain protected; classify or move blockers, "
1089
+ "then rerun land"
1090
+ ),
1091
+ }
1092
+ landing_attempt = landing_start_artifact_inventory(wt, main_tree)
1093
+ if landing_attempt["state"] == "started" and not landing_attempt["newAttempt"]:
1094
+ raise Stop(
1095
+ "cleanup",
1096
+ "unfinished landing generator attempt has no frozen output evidence; "
1097
+ f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
1098
+ )
1099
+ # Retained structural backstop: a journal on the active contract always
1100
+ # carries an empty `generatedFiles`, because a landing-start blocker is
1101
+ # refused before the journal is written. A non-empty list therefore means
1102
+ # tampered or migrated evidence — never a path this run may claim.
1103
+ if landing_attempt["generatedFiles"]:
1104
+ raise Stop(
1105
+ "cleanup",
1106
+ "landing-start generated paths are consumer-owned and protected: "
1107
+ + ", ".join(landing_attempt["generatedFiles"]),
1108
+ )
708
1109
  annahmen = Path(wt) / "ANNAHMEN.md"
709
1110
  if annahmen.is_file():
710
1111
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -716,9 +1117,25 @@ def cmd_land(args) -> dict:
716
1117
 
717
1118
  # Step 0b — push
718
1119
  if wt_exists:
719
- p = git(["push", "-u", "origin", branch], cwd=wt)
720
- if p.returncode != 0:
721
- raise Stop("0b push", "push rejected", (p.stderr or p.stdout).strip()[-2000:])
1120
+ assert landing_attempt is not None
1121
+ if landing_attempt["state"] == "frozen" and landing_attempt["pushSucceeded"]:
1122
+ generated_evidence = freeze_landing_artifact_evidence(
1123
+ wt, main_tree, push_succeeded=True
1124
+ )
1125
+ else:
1126
+ if landing_attempt["state"] == "frozen":
1127
+ # Validate every previously frozen identity before a retry can
1128
+ # invoke the generator-capable pre-push hook.
1129
+ reopen_frozen_landing_attempt(wt, main_tree)
1130
+ p = git(["push", "-u", "origin", branch], cwd=wt)
1131
+ generated_evidence = freeze_landing_artifact_evidence(
1132
+ wt, main_tree, push_succeeded=p.returncode == 0
1133
+ )
1134
+ if p.returncode != 0:
1135
+ raise Stop(
1136
+ "0b push", "push rejected",
1137
+ (p.stderr or p.stdout).strip()[-2000:],
1138
+ )
722
1139
 
723
1140
  # Step 0c — ensure PR + final body
724
1141
  p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
@@ -789,12 +1206,24 @@ def cmd_land(args) -> dict:
789
1206
  # Step 2 — kill the worktree's dev server, then Step 4 — teardown
790
1207
  if wt_exists:
791
1208
  git(["fetch", "origin", "main"], cwd=main_tree, check=True)
792
- cleanup = ensure_worktree_removable(wt, main_tree)
1209
+ generated = tuple(item["path"] for item in generated_evidence)
1210
+ cleanup = ensure_worktree_removable(
1211
+ wt,
1212
+ main_tree,
1213
+ verified_scratch_files=generated,
1214
+ verified_scratch_evidence=generated_evidence,
1215
+ )
793
1216
  report["cleanup_guard"] = {
794
- "active": cleanup is not None,
795
- "assumptions_read": bool(cleanup and cleanup.assumptions),
1217
+ "assumptions_read": bool(cleanup.assumptions),
1218
+ "landing_generated_files": list(generated),
796
1219
  }
797
1220
  report["killed_processes"] = kill_worktree_processes(wt)
1221
+ remove_verified_worktree_scratch(
1222
+ wt,
1223
+ main_tree,
1224
+ cleanup,
1225
+ verified_scratch_evidence=generated_evidence,
1226
+ )
798
1227
  p = git(["worktree", "remove", wt], cwd=main_tree)
799
1228
  if p.returncode != 0:
800
1229
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -922,6 +1351,24 @@ def main() -> int:
922
1351
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
923
1352
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
924
1353
  l.add_argument("--skip-malformed-drift", action="store_true")
1354
+ recovery = l.add_mutually_exclusive_group()
1355
+ recovery.add_argument(
1356
+ ABANDON_ATTEMPT_FLAG,
1357
+ action="store_true",
1358
+ help=(
1359
+ "archive an interrupted pre-freeze landing attempt without deleting "
1360
+ "or claiming its ambiguous files"
1361
+ ),
1362
+ )
1363
+ recovery.add_argument(
1364
+ RECOVER_CANONICAL_CLEANUP_FLAG,
1365
+ action="store_true",
1366
+ help=(
1367
+ "after canonical cleanup-policy drift, revalidate the frozen landing "
1368
+ "evidence against canonical origin/main and resume the merged "
1369
+ "worktree's teardown idempotently"
1370
+ ),
1371
+ )
925
1372
  args = ap.parse_args()
926
1373
 
927
1374
  try:
package/src/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './lib/contributionRouting.mjs';
17
17
  import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
18
18
  import { nonInteractiveUpdateDecision } from './lib/updateDecisions.mjs';
19
+ import { renderRequiredMigration } from './lib/consumerMigrations.mjs';
19
20
  import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
20
21
  import { createCommandAdapter } from '../scripts/release-state.mjs';
21
22
  import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
@@ -43,6 +44,8 @@ export async function runCli({
43
44
  const keepDeleted = args.includes('--keep-deleted');
44
45
  const restoreDeleted = args.includes('--restore-deleted');
45
46
  const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
47
+ // Machine-readable rendering of the same update record the interactive run prints.
48
+ const jsonReport = cmd === 'update' && args.includes('--json');
46
49
  let exitCode = 0;
47
50
 
48
51
  if (cmd === 'update' && keepDeleted && restoreDeleted) {
@@ -57,7 +60,7 @@ export async function runCli({
57
60
  return 1;
58
61
  }
59
62
 
60
- p.intro('agent-workflow-kit');
63
+ if (!jsonReport) p.intro('agent-workflow-kit');
61
64
 
62
65
  try {
63
66
  if (cmd === 'init') {
@@ -94,6 +97,11 @@ export async function runCli({
94
97
  releaseIdentities,
95
98
  routingProfile: routingProfileOptions(yes),
96
99
  });
100
+ if (jsonReport) {
101
+ process.stdout.write(`${JSON.stringify(updateDocument(r), null, 2)}\n`);
102
+ if (r.state === 'failed') return 1;
103
+ return r.state === 'conflicted' ? 2 : 0;
104
+ }
97
105
  printPlan(r);
98
106
  printRoutingProfile(r.routingProfile);
99
107
  for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
@@ -183,12 +191,31 @@ export async function runCli({
183
191
  p.outro('');
184
192
  }
185
193
  } catch (err) {
194
+ if (jsonReport) {
195
+ process.stdout.write(`${JSON.stringify({
196
+ schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION, state: 'failed', error: err.message,
197
+ }, null, 2)}\n`);
198
+ return 1;
199
+ }
186
200
  p.cancel(`Error: ${err.message}`);
187
201
  return 1;
188
202
  }
189
203
  return exitCode;
190
204
  }
191
205
 
206
+ const UPDATE_DOCUMENT_SCHEMA_VERSION = 1;
207
+
208
+ /** The single structured update record; `printPlan` renders the same source. */
209
+ function updateDocument(r) {
210
+ return {
211
+ schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION,
212
+ state: r.state,
213
+ status: r.status ?? null,
214
+ report: r.report,
215
+ ...(r.error ? { error: renderUpdateFailure(r) } : {}),
216
+ };
217
+ }
218
+
192
219
  const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
193
220
  if (invokedPath === fileURLToPath(import.meta.url)) {
194
221
  process.exitCode = await runCli();
@@ -211,6 +238,10 @@ function printPlan(r) {
211
238
  lines.push(`${label}: ${r.availability[key].join(', ') || 'none'}`);
212
239
  }
213
240
  }
241
+ for (const action of r.requiredMigrations ?? []) {
242
+ lines.push(renderRequiredMigration(action));
243
+ lines.push(` ${action.consequence} ${action.remediation}`);
244
+ }
214
245
  for (const owned of r.ownedDiffs ?? []) {
215
246
  lines.push(`${owned.state} ${owned.path}`);
216
247
  if (owned.binary) {