@ikon85/agent-workflow-kit 0.38.0 → 0.40.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/grill-me/SKILL.md +1 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  3. package/.agents/skills/kit-update/SKILL.md +33 -1
  4. package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
  5. package/.agents/skills/setup-workflow/SKILL.md +84 -3
  6. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
  9. package/.agents/skills/wrapup/SKILL.md +24 -0
  10. package/.claude/hooks/drift-guard.py +212 -21
  11. package/.claude/skills/grill-me/SKILL.md +1 -1
  12. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  13. package/.claude/skills/kit-update/SKILL.md +33 -1
  14. package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
  15. package/.claude/skills/setup-workflow/SKILL.md +84 -3
  16. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  17. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  18. package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
  19. package/.claude/skills/wrapup/SKILL.md +24 -0
  20. package/README.md +62 -0
  21. package/agent-workflow-kit.package.json +57 -25
  22. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
  23. package/package.json +1 -1
  24. package/scripts/board_bootstrap.py +367 -0
  25. package/scripts/kit-update-pr.mjs +20 -7
  26. package/scripts/kit-update-pr.test.mjs +29 -0
  27. package/scripts/profile_globs.py +347 -0
  28. package/scripts/test_board_bootstrap.py +348 -0
  29. package/scripts/test_drift_guard_diagnostics.py +295 -0
  30. package/scripts/test_profile_globs.py +280 -0
  31. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  32. package/scripts/test_worktree_ignore_seed.py +320 -0
  33. package/scripts/test_worktree_wrapup_contract.py +588 -0
  34. package/scripts/workflow-advisories/core.py +29 -4
  35. package/scripts/worktree-lifecycle/README.md +53 -4
  36. package/scripts/worktree-lifecycle/core.py +211 -60
  37. package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
  38. package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
  39. package/scripts/wrapup-land.py +179 -34
  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 +16 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """Offer the planning-artifact ignore rules for a consumer `.gitignore`.
3
+
4
+ The shipped skills write `PLAN.md`, `PLAN-REVIEW-LOG.md`, and `ANNAHMEN.md`
5
+ into a session worktree, but `.gitignore` is a consumer file the kit does not
6
+ own: `init` and `update` never touch it. This helper is the one place that
7
+ closes that gap, and it closes it only when a user explicitly approves the
8
+ offer inside `/setup-workflow` (ADR 0008).
9
+
10
+ Contract:
11
+
12
+ - `preview` reads and reports; it never writes.
13
+ - `apply` appends one marker block and nothing else. It never rewrites,
14
+ reorders, or removes an existing line, so a second run is a byte-identical
15
+ no-op once the rules are in place.
16
+ - Rules a consumer already has — by any pattern, including a wildcard — are
17
+ reported as covered and never duplicated.
18
+ - A marker block that the consumer has since edited is theirs: the helper
19
+ blocks instead of repairing it.
20
+ - Nothing here runs during `init`/`update` reconciliation.
21
+
22
+ Usage:
23
+ python3 scripts/worktree-lifecycle/ignore_seed.py preview [--repo PATH] [--json]
24
+ python3 scripts/worktree-lifecycle/ignore_seed.py apply [--repo PATH] [--json]
25
+
26
+ Exit codes: 0 previewed/appended/nothing-to-do · 2 blocked · 1 error.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import subprocess
34
+ import sys
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+
38
+ MANIFEST = Path(__file__).resolve().parent / "plan-artifacts.json"
39
+
40
+ BLOCK_START = "# >>> agent-workflow-kit: planning-artifacts/v1 >>>"
41
+ BLOCK_END = "# <<< agent-workflow-kit: planning-artifacts/v1 <<<"
42
+ BLOCK_NOTE = (
43
+ "# Planning artifacts the agent workflow skills write into a session\n"
44
+ "# worktree. They are session scratch, never part of the change. Added by\n"
45
+ "# setup-workflow on request; this block is yours to edit or delete.\n"
46
+ )
47
+
48
+
49
+ class IgnoreSeedError(RuntimeError):
50
+ """A safe, user-visible refusal."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class IgnorePlan:
55
+ """What an approval would append — and nothing more."""
56
+
57
+ repo: Path
58
+ gitignore_exists: bool
59
+ already_ignored: tuple[str, ...]
60
+ pending: tuple[str, ...]
61
+ tracked: tuple[str, ...]
62
+ block: str | None
63
+ status: str
64
+ detail: str
65
+
66
+ def as_dict(self) -> dict:
67
+ return {
68
+ "status": self.status,
69
+ "detail": self.detail,
70
+ "gitignoreExists": self.gitignore_exists,
71
+ "alreadyIgnored": list(self.already_ignored),
72
+ "pending": list(self.pending),
73
+ "tracked": list(self.tracked),
74
+ "block": self.block,
75
+ }
76
+
77
+
78
+ def load_artifacts(manifest: Path = MANIFEST) -> tuple[str, ...]:
79
+ """Return the kit-declared planning artifacts, in declaration order."""
80
+ try:
81
+ document = json.loads(manifest.read_text(encoding="utf-8"))
82
+ return tuple(entry["path"] for entry in document["artifacts"])
83
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
84
+ raise IgnoreSeedError(f"cannot read planning-artifact manifest: {error}") from error
85
+
86
+
87
+ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
88
+ try:
89
+ return subprocess.run(
90
+ ["git", *args], cwd=repo, capture_output=True, text=True,
91
+ )
92
+ except OSError as error: # pragma: no cover - git absent
93
+ raise IgnoreSeedError(f"cannot run git: {error}") from error
94
+
95
+
96
+ def is_ignored(repo: Path, path: str) -> bool:
97
+ """True when any ignore rule already matches `path`.
98
+
99
+ `--no-index` asks the pattern question, not the index question, so an
100
+ artifact that a consumer once committed still reports its real rule state.
101
+ """
102
+ return _git(repo, "check-ignore", "-q", "--no-index", "--", path).returncode == 0
103
+
104
+
105
+ def is_tracked(repo: Path, path: str) -> bool:
106
+ """True when the artifact is committed — an ignore rule cannot untrack it."""
107
+ result = _git(repo, "ls-files", "--error-unmatch", "--", path)
108
+ return result.returncode == 0
109
+
110
+
111
+ def render_block(paths: tuple[str, ...]) -> str:
112
+ """The exact text an approval appends."""
113
+ listed = "".join(f"{path}\n" for path in paths)
114
+ return f"{BLOCK_START}\n{BLOCK_NOTE}{listed}{BLOCK_END}\n"
115
+
116
+
117
+ def _read_gitignore(repo: Path) -> tuple[bool, str]:
118
+ target = repo / ".gitignore"
119
+ try:
120
+ return True, target.read_text(encoding="utf-8")
121
+ except FileNotFoundError:
122
+ return False, ""
123
+ except OSError as error:
124
+ raise IgnoreSeedError(f"cannot read {target}: {error}") from error
125
+
126
+
127
+ def plan(repo: Path, manifest: Path = MANIFEST) -> IgnorePlan:
128
+ """Report what an approval would change. Reads only."""
129
+ repo = Path(repo)
130
+ if not (repo / ".git").exists():
131
+ raise IgnoreSeedError(f"not a git repository: {repo}")
132
+ artifacts = load_artifacts(manifest)
133
+ exists, text = _read_gitignore(repo)
134
+ covered = tuple(path for path in artifacts if is_ignored(repo, path))
135
+ pending = tuple(path for path in artifacts if path not in covered)
136
+ tracked = tuple(path for path in artifacts if is_tracked(repo, path))
137
+ has_block = BLOCK_START in text
138
+
139
+ if not pending:
140
+ return IgnorePlan(
141
+ repo=repo, gitignore_exists=exists, already_ignored=covered,
142
+ pending=(), tracked=tracked, block=None, status="nothing-to-do",
143
+ detail="every planning artifact is already ignored",
144
+ )
145
+ if has_block:
146
+ return IgnorePlan(
147
+ repo=repo, gitignore_exists=exists, already_ignored=covered,
148
+ pending=pending, tracked=tracked, block=None, status="blocked",
149
+ detail=(
150
+ "the marker block exists but no longer covers "
151
+ f"{', '.join(pending)} — that block is consumer-owned; "
152
+ "edit .gitignore yourself"
153
+ ),
154
+ )
155
+ return IgnorePlan(
156
+ repo=repo, gitignore_exists=exists, already_ignored=covered,
157
+ pending=pending, tracked=tracked, block=render_block(pending),
158
+ status="append",
159
+ detail=f"would append {len(pending)} rule(s) in one marker block",
160
+ )
161
+
162
+
163
+ def apply(repo: Path, manifest: Path = MANIFEST) -> IgnorePlan:
164
+ """Append the previewed block. Never rewrites an existing line."""
165
+ decision = plan(repo, manifest)
166
+ if decision.status != "append":
167
+ return decision
168
+ target = Path(repo) / ".gitignore"
169
+ _, text = _read_gitignore(Path(repo))
170
+ prefix = text
171
+ if prefix and not prefix.endswith("\n"):
172
+ prefix += "\n"
173
+ if prefix and not prefix.endswith("\n\n"):
174
+ prefix += "\n"
175
+ try:
176
+ target.write_text(prefix + decision.block, encoding="utf-8")
177
+ except OSError as error:
178
+ raise IgnoreSeedError(f"cannot write {target}: {error}") from error
179
+ return IgnorePlan(
180
+ repo=decision.repo, gitignore_exists=True,
181
+ already_ignored=decision.already_ignored, pending=decision.pending,
182
+ tracked=decision.tracked, block=decision.block, status="appended",
183
+ detail=f"appended {len(decision.pending)} rule(s) to .gitignore",
184
+ )
185
+
186
+
187
+ def _report(decision: IgnorePlan, *, as_json: bool) -> None:
188
+ if as_json:
189
+ print(json.dumps(decision.as_dict(), indent=2))
190
+ return
191
+ print(f"{decision.status} — {decision.detail}")
192
+ if decision.already_ignored:
193
+ print(f" already ignored: {', '.join(decision.already_ignored)}")
194
+ if decision.tracked:
195
+ print(
196
+ " tracked in git (an ignore rule cannot untrack these): "
197
+ f"{', '.join(decision.tracked)}"
198
+ )
199
+ if decision.block:
200
+ label = "appended" if decision.status == "appended" else "would append"
201
+ print(f" {label}:")
202
+ for line in decision.block.rstrip("\n").split("\n"):
203
+ print(f" {line}")
204
+
205
+
206
+ def parse_args(argv: list[str]) -> argparse.Namespace:
207
+ parser = argparse.ArgumentParser(prog="ignore_seed.py")
208
+ parser.add_argument("command", choices=("preview", "apply"))
209
+ parser.add_argument("--repo", default=".")
210
+ parser.add_argument("--json", action="store_true")
211
+ return parser.parse_args(argv)
212
+
213
+
214
+ def main(argv: list[str]) -> int:
215
+ args = parse_args(argv)
216
+ try:
217
+ decision = plan(args.repo) if args.command == "preview" else apply(args.repo)
218
+ except IgnoreSeedError as error:
219
+ print(str(error), file=sys.stderr)
220
+ return 1
221
+ _report(decision, as_json=args.json)
222
+ return 2 if decision.status == "blocked" else 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,37 @@
1
+ {
2
+ "version": 1,
3
+ "artifacts": [
4
+ {
5
+ "path": "PLAN.md",
6
+ "writtenBy": [
7
+ "grill-me",
8
+ "grill-with-docs",
9
+ "codex-review",
10
+ "grill-me-codex",
11
+ "grill-with-docs-codex",
12
+ "codex-build",
13
+ "orchestrate-wave"
14
+ ],
15
+ "why": "the locked plan a planning session hands to the implementing session"
16
+ },
17
+ {
18
+ "path": "PLAN-REVIEW-LOG.md",
19
+ "writtenBy": [
20
+ "codex-review",
21
+ "grill-me-codex",
22
+ "grill-with-docs-codex",
23
+ "codex-build"
24
+ ],
25
+ "why": "the append-only cross-model review transcript of that plan"
26
+ },
27
+ {
28
+ "path": "ANNAHMEN.md",
29
+ "writtenBy": [
30
+ "tdd",
31
+ "orchestrate-wave",
32
+ "wrapup"
33
+ ],
34
+ "why": "the build-time assumption-drift log wrapup propagates before teardown"
35
+ }
36
+ ]
37
+ }
@@ -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)
@@ -461,9 +469,8 @@ def load_candidate_landing_profile(core, wt: str):
461
469
  return candidate
462
470
 
463
471
 
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)
472
+ def load_merged_canonical_profile(core, main_tree: str):
473
+ """Read the merged canonical cleanup policy from origin/main."""
467
474
  result = core.run(
468
475
  [
469
476
  "git", "show",
@@ -482,6 +489,13 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
482
489
  raise core.LifecycleError(
483
490
  "merged canonical landing artifact policy is not configured"
484
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)
485
499
  if (
486
500
  candidate.landing_generated_artifact_patterns
487
501
  != canonical.landing_generated_artifact_patterns
@@ -489,11 +503,9 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
489
503
  ):
490
504
  raise core.LifecycleError(
491
505
  "worktree cleanup policy differs from merged canonical origin/main"
506
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
492
507
  )
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):
508
+ if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
497
509
  raise core.LifecycleError(
498
510
  "landing attempt is missing before canonical cleanup authorization"
499
511
  )
@@ -504,17 +516,22 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
504
516
  ):
505
517
  raise core.LifecycleError(
506
518
  "landing attempt cleanup policy differs from merged canonical origin/main"
519
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
507
520
  )
508
521
  return canonical
509
522
 
510
523
 
511
- def landing_artifact_baseline_digest(wt: str, main_tree: str) -> str:
512
- core = load_worktree_cleanup_core()
513
- try:
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
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
518
535
 
519
536
 
520
537
  def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
@@ -522,6 +539,10 @@ def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
522
539
  try:
523
540
  profile = load_candidate_landing_profile(core, wt)
524
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
525
546
  except core.LifecycleError as error:
526
547
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
527
548
 
@@ -581,6 +602,16 @@ def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
581
602
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
582
603
 
583
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
+
584
615
  def landing_verified_scratch_files(
585
616
  wt: str,
586
617
  main_tree: str,
@@ -605,10 +636,13 @@ def ensure_worktree_removable(
605
636
  *,
606
637
  verified_scratch_files: tuple[str, ...] = (),
607
638
  verified_scratch_evidence: tuple[dict, ...] = (),
639
+ profile_loader=None,
608
640
  ):
609
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
610
644
  try:
611
- profile = load_canonical_landing_profile(core, wt, main_tree)
645
+ profile = loader(core, wt, main_tree)
612
646
  kwargs = {"merge_target": "origin/main"}
613
647
  paths = (
614
648
  tuple(item["path"] for item in verified_scratch_evidence)
@@ -639,11 +673,13 @@ def remove_verified_worktree_scratch(
639
673
  *,
640
674
  verified_scratch_files: tuple[str, ...] = (),
641
675
  verified_scratch_evidence: tuple[dict, ...] = (),
676
+ profile_loader=None,
642
677
  ):
643
678
  """Re-verify and delete only assessed regular scratch files."""
644
679
  core = load_worktree_cleanup_core()
680
+ loader = profile_loader or load_canonical_landing_profile
645
681
  try:
646
- profile = load_canonical_landing_profile(core, wt, main_tree)
682
+ profile = loader(core, wt, main_tree)
647
683
  evidence = verified_scratch_evidence
648
684
  paths = tuple(item["path"] for item in evidence) or verified_scratch_files
649
685
  latest = core.cleanup_assessment(
@@ -920,6 +956,105 @@ def cmd_commit(args) -> dict:
920
956
  return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
921
957
 
922
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
+
923
1058
  def cmd_land(args) -> dict:
924
1059
  report: dict = {"stops": [], "warnings": []}
925
1060
  main_tree, branches = worktree_map()
@@ -931,13 +1066,13 @@ def cmd_land(args) -> dict:
931
1066
  branch = args.branch
932
1067
  wt = branches.get(branch)
933
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)
934
1071
  profile = load_profile()
935
1072
  default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
936
1073
 
937
1074
  # drift markers from the build-time log — mechanical, no gate
938
1075
  markers: list[dict] = []
939
- artifact_baseline_digest: str | None = None
940
- landing_start_files: tuple[str, ...] = ()
941
1076
  landing_attempt: dict | None = None
942
1077
  generated_evidence: tuple[dict, ...] = ()
943
1078
  if wt_exists:
@@ -959,16 +1094,18 @@ def cmd_land(args) -> dict:
959
1094
  raise Stop(
960
1095
  "cleanup",
961
1096
  "unfinished landing generator attempt has no frozen output evidence; "
962
- "classify its files, then rerun with --abandon-unfinished-attempt",
1097
+ f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
963
1098
  )
964
- landing_start_files = tuple(landing_attempt["generatedFiles"])
965
- if landing_start_files:
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"]:
966
1104
  raise Stop(
967
1105
  "cleanup",
968
1106
  "landing-start generated paths are consumer-owned and protected: "
969
- + ", ".join(landing_start_files),
1107
+ + ", ".join(landing_attempt["generatedFiles"]),
970
1108
  )
971
- artifact_baseline_digest = landing_attempt["baselineDigest"]
972
1109
  annahmen = Path(wt) / "ANNAHMEN.md"
973
1110
  if annahmen.is_file():
974
1111
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -1077,18 +1214,16 @@ def cmd_land(args) -> dict:
1077
1214
  verified_scratch_evidence=generated_evidence,
1078
1215
  )
1079
1216
  report["cleanup_guard"] = {
1080
- "active": cleanup is not None,
1081
- "assumptions_read": bool(cleanup and cleanup.assumptions),
1217
+ "assumptions_read": bool(cleanup.assumptions),
1082
1218
  "landing_generated_files": list(generated),
1083
1219
  }
1084
1220
  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
- )
1221
+ remove_verified_worktree_scratch(
1222
+ wt,
1223
+ main_tree,
1224
+ cleanup,
1225
+ verified_scratch_evidence=generated_evidence,
1226
+ )
1092
1227
  p = git(["worktree", "remove", wt], cwd=main_tree)
1093
1228
  if p.returncode != 0:
1094
1229
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -1216,14 +1351,24 @@ def main() -> int:
1216
1351
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
1217
1352
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
1218
1353
  l.add_argument("--skip-malformed-drift", action="store_true")
1219
- l.add_argument(
1220
- "--abandon-unfinished-attempt",
1354
+ recovery = l.add_mutually_exclusive_group()
1355
+ recovery.add_argument(
1356
+ ABANDON_ATTEMPT_FLAG,
1221
1357
  action="store_true",
1222
1358
  help=(
1223
1359
  "archive an interrupted pre-freeze landing attempt without deleting "
1224
1360
  "or claiming its ambiguous files"
1225
1361
  ),
1226
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
+ )
1227
1372
  args = ap.parse_args()
1228
1373
 
1229
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) {