@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.
- package/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
- package/.agents/skills/setup-workflow/SKILL.md +42 -4
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.agents/skills/wrapup/SKILL.md +46 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
- package/.claude/skills/setup-workflow/SKILL.md +42 -4
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.claude/skills/wrapup/SKILL.md +46 -0
- package/README.md +73 -0
- package/agent-workflow-kit.package.json +49 -25
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -1
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_orchestrate_wave_contract.py +9 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_wrapup_contract.py +1592 -3
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +139 -0
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +22 -51
- package/scripts/worktree-lifecycle/core.py +1206 -5
- package/scripts/worktree-lifecycle/profile.py +38 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +461 -14
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +11 -0
- 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
|
package/scripts/wrapup-land.py
CHANGED
|
@@ -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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
if
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
795
|
-
"
|
|
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) {
|