@ikon85/agent-workflow-kit 0.37.0 → 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.
@@ -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
@@ -437,18 +437,189 @@ def load_worktree_cleanup_core():
437
437
  return module
438
438
 
439
439
 
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
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:
444
512
  core = load_worktree_cleanup_core()
445
513
  try:
446
- profile = core.load_profile(profile_path)
447
- 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(
448
540
  profile,
449
- Path(main_tree),
450
541
  Path(wt),
451
- 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
452
623
  )
453
624
  except core.LifecycleError as error:
454
625
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
@@ -461,6 +632,69 @@ def ensure_worktree_removable(wt: str, main_tree: str):
461
632
  return assessment
462
633
 
463
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
+
464
698
  # ---------- drift log (ANNAHMEN.md) ----------
465
699
 
466
700
  def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
@@ -702,9 +936,39 @@ def cmd_land(args) -> dict:
702
936
 
703
937
  # drift markers from the build-time log — mechanical, no gate
704
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, ...] = ()
705
943
  if wt_exists:
706
944
  if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
707
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"]
708
972
  annahmen = Path(wt) / "ANNAHMEN.md"
709
973
  if annahmen.is_file():
710
974
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -716,9 +980,25 @@ def cmd_land(args) -> dict:
716
980
 
717
981
  # Step 0b — push
718
982
  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:])
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
+ )
722
1002
 
723
1003
  # Step 0c — ensure PR + final body
724
1004
  p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
@@ -789,12 +1069,26 @@ def cmd_land(args) -> dict:
789
1069
  # Step 2 — kill the worktree's dev server, then Step 4 — teardown
790
1070
  if wt_exists:
791
1071
  git(["fetch", "origin", "main"], cwd=main_tree, check=True)
792
- 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
+ )
793
1079
  report["cleanup_guard"] = {
794
1080
  "active": cleanup is not None,
795
1081
  "assumptions_read": bool(cleanup and cleanup.assumptions),
1082
+ "landing_generated_files": list(generated),
796
1083
  }
797
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
+ )
798
1092
  p = git(["worktree", "remove", wt], cwd=main_tree)
799
1093
  if p.returncode != 0:
800
1094
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -922,6 +1216,14 @@ def main() -> int:
922
1216
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
923
1217
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
924
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
+ )
925
1227
  args = ap.parse_args()
926
1228
 
927
1229
  try:
@@ -134,6 +134,7 @@ export const HELPER_FILES = [
134
134
  { path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
135
135
  { path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
136
136
  { path: 'scripts/worktree-lifecycle/cleanup.py', kind: 'script', mode: 0o755 },
137
+ { path: 'scripts/worktree-lifecycle/session.py', kind: 'script', mode: 0o755 },
137
138
  { path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
138
139
  { path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
139
140
  // Shared hook utility imported by the shipped hooks (drift-guard,