@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
@@ -24,11 +24,25 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
24
24
  deletion authority, so setup never derives it from `.gitignore` alone and
25
25
  update never installs a universal default.
26
26
 
27
- Profile globs use repository-relative POSIX semantics. `*` stays inside one
28
- path segment; `**` crosses `/`, and leading `**/` also matches the repository
29
- root. For example, `**/__pycache__/**` matches both root and nested caches,
27
+ Profile globs use one repository-relative POSIX dialect, implemented once in
28
+ `scripts/profile_globs.py` and loaded by this core and by
29
+ `scripts/workflow-advisories/core.py`. `*` and `?` stay inside one path
30
+ segment; `[seq]`/`[!seq]` are per-segment character classes; `**` as a whole
31
+ segment matches zero or more segments, so a leading `**/` also matches the
32
+ repository root and `dir/**` also matches `dir` itself; matching is always
33
+ case-sensitive on every host filesystem; and a pattern must match the whole
34
+ path. For example, `**/__pycache__/**` matches both root and nested caches,
30
35
  while `dist-kit/*` does not match `dist-kit/a/b`.
31
36
 
37
+ Because both capabilities load the same matcher, a pattern can never select one
38
+ set of paths for an advisory and a different set for a deletion decision. Run
39
+ `python3 scripts/profile_globs.py <profile.json>` to review an installed
40
+ profile: it names every pattern whose match set narrows or widens against that
41
+ key's legacy matcher, prints the witness path proving it, marks the keys that
42
+ carry deletion authority, and exits 1 when anything needs review. It reads the
43
+ profile and never rewrites a pattern, so a migration cannot silently expand
44
+ cleanup authority.
45
+
32
46
  Unknown or malformed events fail open without changing repository state.
33
47
  Security-sensitive, profile-matched edits and commands fail closed only when
34
48
  the core proves the target is unsafe.
@@ -44,6 +58,24 @@ the core proves the target is unsafe.
44
58
  | `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
45
59
  | `slice-handoff-hint.py` | UserPromptSubmit | names the configured setup entry for a defined slice |
46
60
 
61
+ ## Planning-artifact ignore rules
62
+
63
+ `plan-artifacts.json` is the kit-owned declaration of the planning artefacts the
64
+ shipped skills write (`PLAN.md`, `PLAN-REVIEW-LOG.md`, `ANNAHMEN.md`).
65
+ `ignore_seed.py` turns that declaration into an offer, never an installation:
66
+ `preview` reports what an approval would append and writes nothing, and `apply`
67
+ appends exactly one marker block. `.gitignore` is a consumer file the kit does
68
+ not own, so only an explicit approved `/setup-workflow` step may run `apply`;
69
+ `init` and `update` reconciliation never reach it (ADR 0008).
70
+
71
+ The append never rewrites, reorders, or removes an existing line. A repository
72
+ that already ignores every artefact reports `nothing-to-do`, a re-run after an
73
+ approval is a byte-identical no-op, and a marker block the consumer has since
74
+ edited reports `blocked` rather than being repaired. An artefact already tracked
75
+ in git is named separately — an ignore rule cannot untrack it, and the helper
76
+ never runs `git rm`. Only after such an approval does the `scratchPatterns`
77
+ derivation have real ignored planning artefacts to read.
78
+
47
79
  ## Cleanup
48
80
 
49
81
  `cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
@@ -83,6 +115,16 @@ checked against that canonical generator policy. A missing policy is distinct
83
115
  from an explicit empty policy. An unmerged or transient branch policy therefore
84
116
  cannot grant itself broader cleanup authority.
85
117
 
118
+ Canonical policy that drifts between attempt start and post-merge cleanup keeps
119
+ that refusal, and the refusal names its supported recovery. Recovery re-derives
120
+ authority from the merged canonical policy alone: it never consults the stale
121
+ worktree candidate, never re-scans for new candidates, requires the branch to
122
+ already be an ancestor of canonical main, and requires each frozen identity to
123
+ be both named by canonical policy and unchanged on disk. Evidence outside
124
+ canonical policy, a changed identity, and pre-existing or foreign state stop the
125
+ recovery instead of being deleted, and a repeated run after a completed teardown
126
+ is a no-op.
127
+
86
128
  The landing adapter may carry exact scratch evidence only for current ignored
87
129
  files that match the consumer-owned
88
130
  `wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
@@ -98,7 +140,14 @@ Landing-start blockers are classified before a journal is written, so moving a
98
140
  protected blocker permits a clean next attempt. An explicit relinquish archives
99
141
  either a started or frozen attempt, including drifted evidence, without
100
142
  deleting or claiming any file; the next preflight treats every current file as
101
- pre-existing. Exact unchanged frozen evidence remains directly resumable.
143
+ pre-existing. Exact unchanged frozen evidence remains directly resumable. The
144
+ attempt journal name is classified without following a symlink, so a symlinked
145
+ or dangling journal entry stops instead of being read or replaced, and each
146
+ archived receipt is filed under a contract-version-neutral stem plus its own
147
+ recorded contract version. A journal written under a superseded contract is
148
+ classified as legacy rather than corrupt when it still satisfies its own
149
+ recorded contract; that refusal names the archive route explicitly, while a
150
+ journal that fails its own contract is still reported as incoherent evidence.
102
151
 
103
152
  `cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
104
153
  every linked worktree and local branch, reports issue/PR/merge/age/removal
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib.util
5
6
  import json
6
7
  import os
7
8
  import re
8
9
  import stat
10
+ import sys
9
11
  from contextlib import contextmanager
10
12
  from dataclasses import dataclass, replace
11
- from fnmatch import fnmatchcase
12
13
  from hashlib import sha256
13
14
  from pathlib import Path, PurePosixPath
14
15
  from time import time
@@ -29,12 +30,50 @@ _BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:mer
29
30
  _BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
30
31
  ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
31
32
  LANDING_ATTEMPT_FILE = "awkit-landing-attempt-v1.json"
33
+ PROFILE_GLOBS_MODULE = "_agent_workflow_kit_profile_globs"
34
+
35
+
36
+ def load_profile_globs():
37
+ """Load the one shared repository-relative glob dialect exactly once."""
38
+ module = sys.modules.get(PROFILE_GLOBS_MODULE)
39
+ if module is not None:
40
+ return module
41
+ path = Path(__file__).resolve().parents[1] / "profile_globs.py"
42
+ spec = importlib.util.spec_from_file_location(PROFILE_GLOBS_MODULE, path)
43
+ if spec is None or spec.loader is None:
44
+ raise ImportError(f"cannot load the shared profile glob dialect from {path}")
45
+ module = importlib.util.module_from_spec(spec)
46
+ sys.modules[PROFILE_GLOBS_MODULE] = module
47
+ spec.loader.exec_module(module)
48
+ return module
49
+
50
+
51
+ # Consumer profile globs are matched here exactly as Workflow Advisories
52
+ # matches its own: one dialect, so an advisory and a deletion decision can
53
+ # never disagree about which repository-relative paths a pattern selects.
54
+ path_glob_matches = load_profile_globs().path_glob_matches
55
+
56
+ # Archived receipts are named from a contract-version-neutral stem plus the
57
+ # archived receipt's own contractVersion, so a v2 receipt is never filed as v1.
58
+ LANDING_ATTEMPT_ARCHIVE_STEM = "awkit-landing-attempt"
59
+ LANDING_ATTEMPT_CONTRACT_VERSION = 2
60
+ LANDING_ATTEMPT_KEYS = (
61
+ "contractVersion", "worktree", "branch", "rootDevice",
62
+ "rootInode", "baselineDigest", "generatedFiles",
63
+ "generatedEvidence", "state", "authorizedEvidence",
64
+ "pushSucceeded",
65
+ )
66
+ ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
32
67
 
33
68
 
34
69
  class BaselineBackfillDeferred(LifecycleError):
35
70
  """A safe legacy baseline cannot be captured until consumer state changes."""
36
71
 
37
72
 
73
+ class LegacyLandingAttempt(LifecycleError):
74
+ """A coherent attempt journal predates the active contract and is not adoptable."""
75
+
76
+
38
77
  def durable_atomic_json(
39
78
  path: Path,
40
79
  document: dict[str, Any],
@@ -88,34 +127,6 @@ def durable_replace(source: Path, destination: Path, *, label: str) -> None:
88
127
  raise LifecycleError(f"cannot {label}: {error}") from error
89
128
 
90
129
 
91
- def path_glob_matches(path: str, pattern: str) -> bool:
92
- """Match POSIX path segments while retaining fnmatch bracket compatibility."""
93
- path_parts = PurePosixPath(path).parts
94
- pattern_parts = PurePosixPath(pattern).parts
95
- memo: dict[tuple[int, int], bool] = {}
96
-
97
- def matches(path_index: int, pattern_index: int) -> bool:
98
- key = (path_index, pattern_index)
99
- if key in memo:
100
- return memo[key]
101
- if pattern_index == len(pattern_parts):
102
- result = path_index == len(path_parts)
103
- elif pattern_parts[pattern_index] == "**":
104
- result = matches(path_index, pattern_index + 1) or (
105
- path_index < len(path_parts)
106
- and matches(path_index + 1, pattern_index)
107
- )
108
- else:
109
- result = (
110
- path_index < len(path_parts)
111
- and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index])
112
- and matches(path_index + 1, pattern_index + 1)
113
- )
114
- memo[key] = result
115
- return result
116
-
117
- return matches(0, 0)
118
-
119
130
  @dataclass(frozen=True)
120
131
  class RepoFacts:
121
132
  root: Path
@@ -209,6 +220,30 @@ def artifact_baseline_path(worktree: Path) -> Path:
209
220
  return git_dir / ARTIFACT_BASELINE_FILE
210
221
 
211
222
 
223
+ def landing_attempt_path(worktree: Path) -> Path:
224
+ """Return the one landing-attempt journal path beside the artifact baseline."""
225
+ return artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
226
+
227
+
228
+ def landing_attempt_exists(path: Path) -> bool:
229
+ """Classify journal presence without following a symlink at that name."""
230
+ return os.path.lexists(path)
231
+
232
+
233
+ def require_regular_landing_attempt(path: Path) -> None:
234
+ """Refuse a journal name occupied by a symlink or any non-regular entry."""
235
+ if path.is_symlink() or not path.is_file():
236
+ raise LifecycleError("landing-attempt provenance is not a regular file")
237
+
238
+
239
+ def landing_attempt_keys(contract_version: Any) -> list[str]:
240
+ """Return the exact journal key set recorded by one contract version."""
241
+ keys = list(LANDING_ATTEMPT_KEYS)
242
+ if contract_version == LANDING_ATTEMPT_CONTRACT_VERSION:
243
+ keys.append("policyDigest")
244
+ return keys
245
+
246
+
212
247
  def _baseline_payload(
213
248
  *,
214
249
  worktree: Path,
@@ -395,8 +430,7 @@ def ensure_artifact_baseline(
395
430
  path = artifact_baseline_path(worktree)
396
431
  if os.path.lexists(path):
397
432
  return load_artifact_baseline(worktree)
398
- attempt_path = path.with_name(LANDING_ATTEMPT_FILE)
399
- if os.path.lexists(attempt_path):
433
+ if landing_attempt_exists(landing_attempt_path(worktree)):
400
434
  raise LifecycleError(
401
435
  "artifact provenance baseline is missing while a landing attempt exists"
402
436
  )
@@ -447,6 +481,36 @@ def verified_landing_scratch_files(
447
481
  )
448
482
 
449
483
 
484
+ def _classify_superseded_landing_attempt(
485
+ payload: dict[str, Any],
486
+ digest: Any,
487
+ baseline: ArtifactBaseline,
488
+ worktree: Path,
489
+ ) -> None:
490
+ """Separate a coherent pre-upgrade journal from genuinely corrupt evidence.
491
+
492
+ A journal written before the active contract can never be adopted, but it is
493
+ not damage: it has an exact, non-deleting route out. Only evidence that also
494
+ fails its own recorded contract is reported as corruption.
495
+ """
496
+ if (
497
+ payload["contractVersion"] != 1
498
+ or payload["worktree"] != str(worktree.resolve())
499
+ or payload["branch"] != baseline.branch
500
+ or (payload["rootDevice"], payload["rootInode"])
501
+ != (baseline.root_device, baseline.root_inode)
502
+ or payload["state"] not in {"started", "frozen"}
503
+ or digest != _baseline_digest(payload)
504
+ ):
505
+ raise LifecycleError("landing-attempt provenance is incoherent")
506
+ raise LegacyLandingAttempt(
507
+ "landing attempt was started under the superseded v1 journal contract "
508
+ "and cannot be adopted; archive it with "
509
+ f"`land {ABANDON_ATTEMPT_FLAG}` — that deletes and claims no file — "
510
+ "then rerun land"
511
+ )
512
+
513
+
450
514
  def landing_start_artifact_inventory(
451
515
  profile: WorktreeProfile,
452
516
  worktree: Path,
@@ -456,31 +520,32 @@ def landing_start_artifact_inventory(
456
520
  worktree,
457
521
  reject_ignored_patterns=profile.landing_generated_artifact_patterns,
458
522
  )
459
- path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
460
- if path.exists():
523
+ attempt_path = landing_attempt_path(worktree)
524
+ if landing_attempt_exists(attempt_path):
525
+ require_regular_landing_attempt(attempt_path)
461
526
  try:
462
- document = json.loads(path.read_text(encoding="utf-8"))
527
+ document = json.loads(attempt_path.read_text(encoding="utf-8"))
528
+ contract_version = document["contractVersion"]
463
529
  payload = {
464
530
  key: document[key]
465
- for key in (
466
- "contractVersion", "worktree", "branch", "rootDevice",
467
- "rootInode", "baselineDigest", "generatedFiles",
468
- "generatedEvidence", "state", "authorizedEvidence",
469
- "pushSucceeded", "policyDigest",
470
- )
531
+ for key in landing_attempt_keys(contract_version)
471
532
  }
472
533
  digest = document["sha256"]
473
534
  except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
474
535
  raise LifecycleError(
475
536
  f"landing-attempt provenance is incoherent: {error}"
476
537
  ) from error
538
+ if contract_version != LANDING_ATTEMPT_CONTRACT_VERSION:
539
+ _classify_superseded_landing_attempt(
540
+ payload, digest, baseline, worktree
541
+ )
477
542
  if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
478
543
  raise LifecycleError(
479
544
  "landing cleanup policy changed after attempt start; "
480
545
  "abandon the unfinished attempt before retrying"
481
546
  )
482
547
  if (
483
- payload["contractVersion"] != 2
548
+ payload["contractVersion"] != LANDING_ATTEMPT_CONTRACT_VERSION
484
549
  or payload["worktree"] != str(worktree.resolve())
485
550
  or payload["branch"] != baseline.branch
486
551
  or (payload["rootDevice"], payload["rootInode"])
@@ -528,7 +593,7 @@ def landing_start_artifact_inventory(
528
593
  "policyDigest": landing_cleanup_policy_digest(profile),
529
594
  }
530
595
  payload = {
531
- "contractVersion": 2,
596
+ "contractVersion": LANDING_ATTEMPT_CONTRACT_VERSION,
532
597
  "worktree": str(worktree.resolve()),
533
598
  "branch": baseline.branch,
534
599
  "rootDevice": baseline.root_device,
@@ -537,7 +602,7 @@ def landing_start_artifact_inventory(
537
602
  }
538
603
  digest = _baseline_digest(payload)
539
604
  durable_atomic_json(
540
- path,
605
+ attempt_path,
541
606
  {**payload, "sha256": digest},
542
607
  label="persist landing-attempt provenance",
543
608
  )
@@ -620,7 +685,7 @@ def freeze_landing_artifact_evidence(
620
685
  )
621
686
  if attempt["state"] == "frozen":
622
687
  return frozen
623
- path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
688
+ path = landing_attempt_path(worktree)
624
689
  document = json.loads(path.read_text(encoding="utf-8"))
625
690
  payload = {
626
691
  key: document[key]
@@ -652,7 +717,7 @@ def reopen_frozen_landing_attempt(
652
717
  frozen = freeze_landing_artifact_evidence(
653
718
  profile, worktree, push_succeeded=False
654
719
  )
655
- path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
720
+ path = landing_attempt_path(worktree)
656
721
  document = json.loads(path.read_text(encoding="utf-8"))
657
722
  payload = {
658
723
  key: document[key]
@@ -680,25 +745,15 @@ def abandon_unfinished_landing_attempt(
680
745
  worktree: Path,
681
746
  ) -> Path:
682
747
  """Archive an ambiguous started attempt without claiming or deleting files."""
683
- path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
684
- if not os.path.lexists(path):
748
+ path = landing_attempt_path(worktree)
749
+ if not landing_attempt_exists(path):
685
750
  raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
686
751
  try:
687
- if path.is_symlink() or not path.is_file():
688
- raise LifecycleError("landing-attempt provenance is not a regular file")
752
+ require_regular_landing_attempt(path)
689
753
  document = json.loads(path.read_text(encoding="utf-8"))
690
- contract_version = document["contractVersion"]
691
- keys = [
692
- "contractVersion", "worktree", "branch", "rootDevice",
693
- "rootInode", "baselineDigest", "generatedFiles",
694
- "generatedEvidence", "state", "authorizedEvidence",
695
- "pushSucceeded",
696
- ]
697
- if contract_version == 2:
698
- keys.append("policyDigest")
699
754
  payload = {
700
755
  key: document[key]
701
- for key in keys
756
+ for key in landing_attempt_keys(document["contractVersion"])
702
757
  }
703
758
  digest = document["sha256"]
704
759
  metadata = os.lstat(worktree)
@@ -719,12 +774,108 @@ def abandon_unfinished_landing_attempt(
719
774
  ):
720
775
  raise LifecycleError("landing-attempt provenance is incoherent")
721
776
  archive = path.with_name(
722
- f"{path.stem}.abandoned-{int(time() * 1_000_000)}.json"
777
+ f"{LANDING_ATTEMPT_ARCHIVE_STEM}.v{payload['contractVersion']}"
778
+ f".abandoned-{int(time() * 1_000_000)}.json"
723
779
  )
724
780
  durable_replace(path, archive, label="archive landing attempt")
725
781
  return archive
726
782
 
727
783
 
784
+ def canonical_recovery_evidence(
785
+ canonical: WorktreeProfile,
786
+ worktree: Path,
787
+ ) -> tuple[dict[str, Any], ...]:
788
+ """Revalidate one frozen landing attempt against canonical policy alone.
789
+
790
+ Canonical cleanup-policy drift between attempt start and post-merge cleanup
791
+ strands an already-merged worktree, because the attempt is bound to the
792
+ policy that nominated it. This route re-derives deletion authority from the
793
+ merged canonical policy only. It never re-scans the worktree for new
794
+ candidates, so broader stale candidate evidence cannot enter; every frozen
795
+ identity must additionally still be named by canonical policy and still
796
+ match byte-for-byte on disk, so a narrowed canonical policy, a changed file,
797
+ and any pre-existing state stop the recovery instead of being deleted.
798
+ """
799
+ path = landing_attempt_path(worktree)
800
+ if not landing_attempt_exists(path):
801
+ raise LifecycleError(
802
+ "canonical cleanup recovery requires the landing attempt that froze "
803
+ "this teardown's evidence"
804
+ )
805
+ try:
806
+ require_regular_landing_attempt(path)
807
+ document = json.loads(path.read_text(encoding="utf-8"))
808
+ contract_version = document["contractVersion"]
809
+ payload = {
810
+ key: document[key]
811
+ for key in landing_attempt_keys(contract_version)
812
+ }
813
+ digest = document["sha256"]
814
+ metadata = os.lstat(worktree)
815
+ branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
816
+ except LifecycleError:
817
+ raise
818
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
819
+ raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
820
+ if (
821
+ contract_version != LANDING_ATTEMPT_CONTRACT_VERSION
822
+ or payload["worktree"] != str(worktree.resolve())
823
+ or payload["branch"] != branch
824
+ or not stat.S_ISDIR(metadata.st_mode)
825
+ or (payload["rootDevice"], payload["rootInode"])
826
+ != (metadata.st_dev, metadata.st_ino)
827
+ or not isinstance(payload["generatedFiles"], list)
828
+ or not isinstance(payload["authorizedEvidence"], list)
829
+ or type(payload["pushSucceeded"]) is not bool
830
+ or digest != _baseline_digest(payload)
831
+ ):
832
+ raise LifecycleError("landing-attempt provenance is incoherent")
833
+ if payload["state"] != "frozen" or not payload["pushSucceeded"]:
834
+ raise LifecycleError(
835
+ "canonical cleanup recovery requires a frozen landing attempt from a "
836
+ f"completed push; archive an unfinished attempt with `{ABANDON_ATTEMPT_FLAG}`"
837
+ )
838
+ if payload["generatedFiles"]:
839
+ raise LifecycleError(
840
+ "landing-start generated paths are consumer-owned and protected: "
841
+ + ", ".join(payload["generatedFiles"])
842
+ )
843
+ baseline = load_artifact_baseline(worktree)
844
+ if payload["baselineDigest"] != baseline.digest:
845
+ raise LifecycleError("artifact provenance baseline changed during landing")
846
+ frozen = tuple(payload["authorizedEvidence"])
847
+ relatives: list[str] = []
848
+ for item in frozen:
849
+ relative = item.get("path") if isinstance(item, dict) else None
850
+ if not isinstance(relative, str) or relative in relatives:
851
+ raise LifecycleError("scratch evidence is incoherent")
852
+ relatives.append(relative)
853
+ outside = sorted(
854
+ relative for relative in relatives
855
+ if not any(
856
+ path_glob_matches(relative, pattern)
857
+ for pattern in canonical.landing_generated_artifact_patterns
858
+ )
859
+ )
860
+ if outside:
861
+ raise LifecycleError(
862
+ "landing-attempt evidence is outside canonical cleanup policy: "
863
+ + ", ".join(outside)
864
+ )
865
+ with verified_worktree_root(
866
+ worktree,
867
+ baseline.root_device,
868
+ baseline.root_inode,
869
+ ) as descriptor:
870
+ current = tuple(
871
+ contained_regular_identity(descriptor, relative)
872
+ for relative in relatives
873
+ )
874
+ if current != frozen:
875
+ raise LifecycleError("landing-generated evidence changed after it was frozen")
876
+ return current
877
+
878
+
728
879
  def collect_facts(cwd: Path) -> RepoFacts:
729
880
  root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
730
881
  main = main_worktree(root)