@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,25 +1,26 @@
1
- """Consumer-neutral Worktree Lifecycle facts and decisions."""
1
+ """Consumer-neutral Worktree Lifecycle facts and decisions.
2
+
3
+ Teardown authority is not stored here. `classify.py` derives it from the
4
+ repository's current state at the moment of action, and this module consumes
5
+ exactly that assessment: it adds only the facts git status cannot answer — is
6
+ the path a registered worktree, is its branch protected, is there an open PR,
7
+ is the branch merged — and renders the one classification report instead of
8
+ formatting a second one.
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
- import importlib.util
6
- import json
7
- import os
8
13
  import re
9
- import stat
10
- import sys
11
- from contextlib import contextmanager
12
- from dataclasses import dataclass, replace
13
- from hashlib import sha256
14
- from pathlib import Path, PurePosixPath
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
15
16
  from time import time
16
17
  from typing import Any, Callable
17
18
 
19
+ from classify import ClassificationError, assess, render_report
18
20
  from profile import (
19
21
  LifecycleError,
20
22
  WorktreeProfile,
21
23
  load_profile,
22
- load_profile_text,
23
24
  local_branch_exists,
24
25
  main_worktree,
25
26
  registered_worktrees,
@@ -28,103 +29,6 @@ from profile import (
28
29
 
29
30
  _BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
30
31
  _BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
31
- ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
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"
67
-
68
-
69
- class BaselineBackfillDeferred(LifecycleError):
70
- """A safe legacy baseline cannot be captured until consumer state changes."""
71
-
72
-
73
- class LegacyLandingAttempt(LifecycleError):
74
- """A coherent attempt journal predates the active contract and is not adoptable."""
75
-
76
-
77
- def durable_atomic_json(
78
- path: Path,
79
- document: dict[str, Any],
80
- *,
81
- label: str,
82
- mode: int = 0o666,
83
- sort_keys: bool = False,
84
- ) -> None:
85
- """Atomically replace one JSON journal and durably publish its directory entry."""
86
- temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
87
- try:
88
- descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
89
- with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
90
- json.dump(
91
- document,
92
- handle,
93
- ensure_ascii=False,
94
- indent=2,
95
- sort_keys=sort_keys,
96
- )
97
- handle.write("\n")
98
- handle.flush()
99
- os.fsync(handle.fileno())
100
- os.replace(temporary, path)
101
- directory_descriptor = os.open(
102
- path.parent,
103
- os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
104
- )
105
- try:
106
- os.fsync(directory_descriptor)
107
- finally:
108
- os.close(directory_descriptor)
109
- except OSError as error:
110
- temporary.unlink(missing_ok=True)
111
- raise LifecycleError(f"cannot {label}: {error}") from error
112
-
113
-
114
- def durable_replace(source: Path, destination: Path, *, label: str) -> None:
115
- """Rename one journal durably without inspecting or claiming its payload files."""
116
- try:
117
- os.replace(source, destination)
118
- directory_descriptor = os.open(
119
- destination.parent,
120
- os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
121
- )
122
- try:
123
- os.fsync(directory_descriptor)
124
- finally:
125
- os.close(directory_descriptor)
126
- except OSError as error:
127
- raise LifecycleError(f"cannot {label}: {error}") from error
128
32
 
129
33
 
130
34
  @dataclass(frozen=True)
@@ -145,735 +49,35 @@ class Decision:
145
49
  event_name: str = ""
146
50
 
147
51
 
148
- @dataclass(frozen=True)
149
- class CleanupAssessment:
150
- worktree: Path
151
- branch: str
152
- assumptions: str
153
- reasons: tuple[str, ...]
154
- root_device: int
155
- root_inode: int
156
- scratch_files: tuple[str, ...] = ()
157
- scratch_evidence: tuple[dict[str, Any], ...] = ()
158
-
159
- @property
160
- def removable(self) -> bool:
161
- return not self.reasons
162
-
163
-
164
52
  @dataclass(frozen=True)
165
53
  class CleanupFacts:
166
54
  worktree: Path
167
55
  branch: str
168
56
  registered: bool
169
57
  is_main: bool
170
- tracked_files: tuple[str, ...]
171
- untracked_files: tuple[str, ...]
58
+ classification: Any
172
59
  merged: bool
173
60
  pr_state: str
174
61
  assumptions: str
175
- root_device: int
176
- root_inode: int
177
62
 
178
63
 
179
64
  @dataclass(frozen=True)
180
- class ArtifactBaseline:
65
+ class CleanupAssessment:
181
66
  worktree: Path
182
67
  branch: str
183
- root_device: int
184
- root_inode: int
185
- setup_head: str
186
- initial_ignored_files: tuple[str, ...]
187
- initial_untracked_files: tuple[str, ...]
188
- digest: str
189
-
190
-
191
- def ignored_file_inventory(worktree: Path) -> tuple[str, ...]:
192
- result = run(
193
- [
194
- "git", "ls-files", "--others", "--ignored",
195
- "--exclude-standard", "-z",
196
- ],
197
- cwd=worktree,
198
- )
199
- return tuple(sorted(path for path in result.stdout.split("\0") if path))
200
-
201
-
202
- def untracked_file_inventory(worktree: Path) -> tuple[str, ...]:
203
- ordinary = run(
204
- ["git", "ls-files", "--others", "--exclude-standard", "-z"],
205
- cwd=worktree,
206
- ).stdout.split("\0")
207
- return tuple(sorted(
208
- set(path for path in ordinary if path).union(ignored_file_inventory(worktree))
209
- ))
210
-
211
-
212
- def artifact_baseline_path(worktree: Path) -> Path:
213
- result = run(
214
- ["git", "rev-parse", "--absolute-git-dir"],
215
- cwd=worktree,
216
- )
217
- git_dir = Path(result.stdout.strip())
218
- if not git_dir.is_absolute():
219
- raise LifecycleError("artifact provenance baseline git dir is not absolute")
220
- return git_dir / ARTIFACT_BASELINE_FILE
221
-
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
-
247
- def _baseline_payload(
248
- *,
249
- worktree: Path,
250
- branch: str,
251
- root_device: int,
252
- root_inode: int,
253
- setup_head: str,
254
- initial_ignored_files: tuple[str, ...],
255
- initial_untracked_files: tuple[str, ...],
256
- ) -> dict[str, Any]:
257
- return {
258
- "contractVersion": 2,
259
- "worktree": str(worktree),
260
- "branch": branch,
261
- "rootDevice": root_device,
262
- "rootInode": root_inode,
263
- "setupHead": setup_head,
264
- "initialIgnoredFiles": list(initial_ignored_files),
265
- "initialUntrackedFiles": list(initial_untracked_files),
266
- }
267
-
268
-
269
- def _baseline_digest(payload: dict[str, Any]) -> str:
270
- encoded = json.dumps(
271
- payload,
272
- ensure_ascii=False,
273
- separators=(",", ":"),
274
- sort_keys=True,
275
- ).encode("utf-8")
276
- return sha256(encoded).hexdigest()
277
-
278
-
279
- def landing_cleanup_policy_digest(profile: WorktreeProfile) -> str:
280
- """Bind one attempt to the exact ordered policy that nominated its files."""
281
- return _baseline_digest({
282
- "scratchPatterns": list(profile.scratch_patterns),
283
- "landingGeneratedArtifactPatterns": list(
284
- profile.landing_generated_artifact_patterns
285
- ),
286
- })
287
-
288
-
289
- def capture_artifact_baseline(
290
- worktree: Path,
291
- *,
292
- reject_ignored_patterns: tuple[str, ...] = (),
293
- ) -> ArtifactBaseline:
294
- worktree = worktree.resolve()
295
- metadata = worktree.stat()
296
- branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
297
- setup_head = run(["git", "rev-parse", "HEAD"], cwd=worktree).stdout.strip()
298
- ignored = ignored_file_inventory(worktree)
299
- untracked = untracked_file_inventory(worktree)
300
- blocked = tuple(sorted(
301
- path
302
- for path in ignored
303
- if any(path_glob_matches(path, pattern) for pattern in reject_ignored_patterns)
304
- ))
305
- if blocked:
306
- raise BaselineBackfillDeferred(
307
- "landing-start generated paths are consumer-owned and protected: "
308
- + ", ".join(blocked)
309
- )
310
- payload = _baseline_payload(
311
- worktree=worktree,
312
- branch=branch,
313
- root_device=metadata.st_dev,
314
- root_inode=metadata.st_ino,
315
- setup_head=setup_head,
316
- initial_ignored_files=ignored,
317
- initial_untracked_files=untracked,
318
- )
319
- digest = _baseline_digest(payload)
320
- path = artifact_baseline_path(worktree)
321
- durable_atomic_json(
322
- path,
323
- {**payload, "sha256": digest},
324
- label="write artifact provenance baseline",
325
- )
326
- return ArtifactBaseline(
327
- worktree,
328
- branch,
329
- metadata.st_dev,
330
- metadata.st_ino,
331
- setup_head,
332
- ignored,
333
- untracked,
334
- digest,
335
- )
336
-
337
-
338
- def load_artifact_baseline(worktree: Path) -> ArtifactBaseline:
339
- worktree = worktree.resolve()
340
- path = artifact_baseline_path(worktree)
341
- try:
342
- if path.is_symlink() or not path.is_file():
343
- raise LifecycleError("artifact provenance baseline is missing or not a regular file")
344
- document = json.loads(path.read_text(encoding="utf-8"))
345
- payload = {
346
- key: document[key]
347
- for key in (
348
- "contractVersion",
349
- "worktree",
350
- "branch",
351
- "rootDevice",
352
- "rootInode",
353
- "setupHead",
354
- "initialIgnoredFiles",
355
- "initialUntrackedFiles",
356
- )
357
- }
358
- digest = document["sha256"]
359
- except LifecycleError:
360
- raise
361
- except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
362
- raise LifecycleError(f"artifact provenance baseline is incoherent: {error}") from error
363
- ignored = payload["initialIgnoredFiles"]
364
- untracked = payload["initialUntrackedFiles"]
365
- if (
366
- payload["contractVersion"] != 2
367
- or not isinstance(payload["worktree"], str)
368
- or not isinstance(payload["branch"], str)
369
- or not payload["branch"]
370
- or type(payload["rootDevice"]) is not int
371
- or type(payload["rootInode"]) is not int
372
- or not isinstance(payload["setupHead"], str)
373
- or re.fullmatch(r"[0-9a-f]{40,64}", payload["setupHead"]) is None
374
- or not isinstance(ignored, list)
375
- or not all(
376
- isinstance(path_value, str)
377
- and path_value
378
- and not PurePosixPath(path_value).is_absolute()
379
- and ".." not in PurePosixPath(path_value).parts
380
- for path_value in ignored
381
- )
382
- or ignored != sorted(set(ignored))
383
- or not isinstance(untracked, list)
384
- or not all(
385
- isinstance(path_value, str)
386
- and path_value
387
- and not PurePosixPath(path_value).is_absolute()
388
- and ".." not in PurePosixPath(path_value).parts
389
- for path_value in untracked
390
- )
391
- or untracked != sorted(set(untracked))
392
- or not set(ignored).issubset(untracked)
393
- or not isinstance(digest, str)
394
- or re.fullmatch(r"[0-9a-f]{64}", digest) is None
395
- or digest != _baseline_digest(payload)
396
- ):
397
- raise LifecycleError("artifact provenance baseline is incoherent")
398
- try:
399
- metadata = worktree.stat()
400
- branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
401
- except (OSError, LifecycleError) as error:
402
- raise LifecycleError(
403
- f"artifact provenance baseline binding cannot be verified: {error}"
404
- ) from error
405
- if (
406
- payload["worktree"] != str(worktree)
407
- or payload["branch"] != branch
408
- or (payload["rootDevice"], payload["rootInode"])
409
- != (metadata.st_dev, metadata.st_ino)
410
- ):
411
- raise LifecycleError("artifact provenance baseline binding does not match worktree")
412
- return ArtifactBaseline(
413
- worktree,
414
- branch,
415
- metadata.st_dev,
416
- metadata.st_ino,
417
- payload["setupHead"],
418
- tuple(ignored),
419
- tuple(untracked),
420
- digest,
421
- )
422
-
423
-
424
- def ensure_artifact_baseline(
425
- worktree: Path,
426
- *,
427
- reject_ignored_patterns: tuple[str, ...] = (),
428
- ) -> ArtifactBaseline:
429
- """Load provenance or conservatively backfill one exact clean legacy worktree."""
430
- path = artifact_baseline_path(worktree)
431
- if os.path.lexists(path):
432
- return load_artifact_baseline(worktree)
433
- if landing_attempt_exists(landing_attempt_path(worktree)):
434
- raise LifecycleError(
435
- "artifact provenance baseline is missing while a landing attempt exists"
436
- )
437
- absolute = worktree.absolute()
438
- try:
439
- metadata = os.lstat(absolute)
440
- except OSError as error:
441
- raise LifecycleError(
442
- f"legacy artifact baseline root cannot be inspected: {error}"
443
- ) from error
444
- if not stat.S_ISDIR(metadata.st_mode) or absolute != worktree.resolve():
445
- raise LifecycleError("legacy artifact baseline root is not an exact nofollow directory")
446
- main = main_worktree(worktree)
447
- if worktree.resolve() not in registered_worktrees(main):
448
- raise LifecycleError("legacy artifact baseline requires an exact registered worktree")
449
- branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
450
- if not branch:
451
- raise LifecycleError("legacy artifact baseline requires an attached branch")
452
- for command in (
453
- ["git", "diff", "--quiet"],
454
- ["git", "diff", "--cached", "--quiet"],
455
- ):
456
- if run(command, cwd=worktree, check=False).returncode != 0:
457
- raise BaselineBackfillDeferred(
458
- "legacy artifact baseline requires a clean tracked worktree and index"
459
- )
460
- return capture_artifact_baseline(
461
- worktree,
462
- reject_ignored_patterns=reject_ignored_patterns,
463
- )
464
-
465
-
466
- def verified_landing_scratch_files(
467
- profile: WorktreeProfile,
468
- worktree: Path,
469
- *,
470
- expected_baseline_digest: str | None = None,
471
- landing_start_files: tuple[str, ...] = (),
472
- ) -> tuple[str, ...]:
473
- return tuple(
474
- item["path"]
475
- for item in verified_landing_scratch_evidence(
476
- profile,
477
- worktree,
478
- expected_baseline_digest=expected_baseline_digest,
479
- landing_start_files=landing_start_files,
480
- )
481
- )
482
-
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
-
514
- def landing_start_artifact_inventory(
515
- profile: WorktreeProfile,
516
- worktree: Path,
517
- ) -> dict[str, Any]:
518
- """Persist/reuse the generated-path inventory preceding the landing build."""
519
- baseline = ensure_artifact_baseline(
520
- worktree,
521
- reject_ignored_patterns=profile.landing_generated_artifact_patterns,
522
- )
523
- attempt_path = landing_attempt_path(worktree)
524
- if landing_attempt_exists(attempt_path):
525
- require_regular_landing_attempt(attempt_path)
526
- try:
527
- document = json.loads(attempt_path.read_text(encoding="utf-8"))
528
- contract_version = document["contractVersion"]
529
- payload = {
530
- key: document[key]
531
- for key in landing_attempt_keys(contract_version)
532
- }
533
- digest = document["sha256"]
534
- except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
535
- raise LifecycleError(
536
- f"landing-attempt provenance is incoherent: {error}"
537
- ) from error
538
- if contract_version != LANDING_ATTEMPT_CONTRACT_VERSION:
539
- _classify_superseded_landing_attempt(
540
- payload, digest, baseline, worktree
541
- )
542
- if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
543
- raise LifecycleError(
544
- "landing cleanup policy changed after attempt start; "
545
- "abandon the unfinished attempt before retrying"
546
- )
547
- if (
548
- payload["contractVersion"] != LANDING_ATTEMPT_CONTRACT_VERSION
549
- or payload["worktree"] != str(worktree.resolve())
550
- or payload["branch"] != baseline.branch
551
- or (payload["rootDevice"], payload["rootInode"])
552
- != (baseline.root_device, baseline.root_inode)
553
- or payload["baselineDigest"] != baseline.digest
554
- or not isinstance(payload["generatedFiles"], list)
555
- or payload["generatedFiles"] != sorted(set(payload["generatedFiles"]))
556
- or not isinstance(payload["generatedEvidence"], list)
557
- or payload["state"] not in {"started", "frozen"}
558
- or not isinstance(payload["authorizedEvidence"], list)
559
- or type(payload["pushSucceeded"]) is not bool
560
- or digest != _baseline_digest(payload)
561
- ):
562
- raise LifecycleError("landing-attempt provenance is incoherent")
563
- return {
564
- "baselineDigest": payload["baselineDigest"],
565
- "generatedFiles": payload["generatedFiles"],
566
- "generatedEvidence": payload["generatedEvidence"],
567
- "state": payload["state"],
568
- "authorizedEvidence": payload["authorizedEvidence"],
569
- "pushSucceeded": payload["pushSucceeded"],
570
- "policyDigest": payload["policyDigest"],
571
- "newAttempt": False,
572
- }
573
- current = set(ignored_file_inventory(worktree))
574
- generated = tuple(sorted(
575
- path for path in current
576
- if any(
577
- path_glob_matches(path, pattern)
578
- for pattern in profile.landing_generated_artifact_patterns
579
- )
580
- ))
581
- if generated:
582
- raise LifecycleError(
583
- "landing-start generated paths are consumer-owned and protected: "
584
- + ", ".join(generated)
585
- )
586
- result = {
587
- "baselineDigest": baseline.digest,
588
- "generatedFiles": list(generated),
589
- "generatedEvidence": [],
590
- "state": "started",
591
- "authorizedEvidence": [],
592
- "pushSucceeded": False,
593
- "policyDigest": landing_cleanup_policy_digest(profile),
594
- }
595
- payload = {
596
- "contractVersion": LANDING_ATTEMPT_CONTRACT_VERSION,
597
- "worktree": str(worktree.resolve()),
598
- "branch": baseline.branch,
599
- "rootDevice": baseline.root_device,
600
- "rootInode": baseline.root_inode,
601
- **result,
602
- }
603
- digest = _baseline_digest(payload)
604
- durable_atomic_json(
605
- attempt_path,
606
- {**payload, "sha256": digest},
607
- label="persist landing-attempt provenance",
608
- )
609
- return {**result, "newAttempt": True}
610
-
611
-
612
- def verified_landing_scratch_evidence(
613
- profile: WorktreeProfile,
614
- worktree: Path,
615
- *,
616
- expected_baseline_digest: str | None = None,
617
- landing_start_files: tuple[str, ...] = (),
618
- ) -> tuple[dict[str, Any], ...]:
619
- """Return frozen regular-file identities for the authorized generator delta."""
620
- baseline = load_artifact_baseline(worktree)
621
- if (
622
- expected_baseline_digest is not None
623
- and baseline.digest != expected_baseline_digest
624
- ):
625
- raise LifecycleError("artifact provenance baseline changed during landing")
626
- current = set(ignored_file_inventory(worktree))
627
- initial = current.intersection(baseline.initial_ignored_files)
628
- initial_generated = sorted(
629
- path for path in initial
630
- if any(
631
- path_glob_matches(path, pattern)
632
- for pattern in profile.landing_generated_artifact_patterns
633
- )
634
- )
635
- if initial_generated:
636
- raise LifecycleError(
637
- "artifact provenance baseline protects initial generated paths: "
638
- + ", ".join(initial_generated)
639
- )
640
- if landing_start_files:
641
- raise LifecycleError(
642
- "landing-start generated paths are consumer-owned and protected: "
643
- + ", ".join(sorted(landing_start_files))
644
- )
645
- candidates = tuple(sorted(
646
- path
647
- for path in current.difference(baseline.initial_ignored_files)
648
- if path not in landing_start_files
649
- if any(
650
- path_glob_matches(path, pattern)
651
- for pattern in profile.landing_generated_artifact_patterns
652
- )
653
- ))
654
- if not candidates:
655
- return ()
656
- with verified_worktree_root(
657
- worktree,
658
- baseline.root_device,
659
- baseline.root_inode,
660
- ) as descriptor:
661
- return tuple(
662
- contained_regular_identity(descriptor, relative)
663
- for relative in candidates
664
- )
665
-
666
-
667
- def freeze_landing_artifact_evidence(
668
- profile: WorktreeProfile,
669
- worktree: Path,
670
- *,
671
- push_succeeded: bool,
672
- ) -> tuple[dict[str, Any], ...]:
673
- """Freeze or revalidate the exact output of one generator-capable push."""
674
- attempt = landing_start_artifact_inventory(profile, worktree)
675
- current = verified_landing_scratch_evidence(
676
- profile,
677
- worktree,
678
- expected_baseline_digest=attempt["baselineDigest"],
679
- landing_start_files=tuple(attempt["generatedFiles"]),
680
- )
681
- frozen = tuple(attempt["authorizedEvidence"])
682
- if attempt["state"] == "frozen" and frozen != current:
683
- raise LifecycleError(
684
- "landing-generated evidence changed after it was frozen"
685
- )
686
- if attempt["state"] == "frozen":
687
- return frozen
688
- path = landing_attempt_path(worktree)
689
- document = json.loads(path.read_text(encoding="utf-8"))
690
- payload = {
691
- key: document[key]
692
- for key in (
693
- "contractVersion", "worktree", "branch", "rootDevice",
694
- "rootInode", "baselineDigest", "generatedFiles",
695
- "generatedEvidence", "policyDigest",
696
- )
697
- }
698
- payload.update({
699
- "state": "frozen",
700
- "authorizedEvidence": list(current),
701
- "pushSucceeded": push_succeeded,
702
- })
703
- digest = _baseline_digest(payload)
704
- durable_atomic_json(
705
- path,
706
- {**payload, "sha256": digest},
707
- label="freeze landing-generated evidence",
708
- )
709
- return current
710
-
711
-
712
- def reopen_frozen_landing_attempt(
713
- profile: WorktreeProfile,
714
- worktree: Path,
715
- ) -> tuple[dict[str, Any], ...]:
716
- """Validate a failed push boundary before permitting its generator to retry."""
717
- frozen = freeze_landing_artifact_evidence(
718
- profile, worktree, push_succeeded=False
719
- )
720
- path = landing_attempt_path(worktree)
721
- document = json.loads(path.read_text(encoding="utf-8"))
722
- payload = {
723
- key: document[key]
724
- for key in (
725
- "contractVersion", "worktree", "branch", "rootDevice",
726
- "rootInode", "baselineDigest", "generatedFiles",
727
- "generatedEvidence", "policyDigest",
728
- )
729
- }
730
- payload.update({
731
- "state": "started",
732
- "authorizedEvidence": [],
733
- "pushSucceeded": False,
734
- })
735
- digest = _baseline_digest(payload)
736
- durable_atomic_json(
737
- path,
738
- {**payload, "sha256": digest},
739
- label="reopen validated landing attempt",
740
- )
741
- return frozen
742
-
743
-
744
- def abandon_unfinished_landing_attempt(
745
- worktree: Path,
746
- ) -> Path:
747
- """Archive an ambiguous started attempt without claiming or deleting files."""
748
- path = landing_attempt_path(worktree)
749
- if not landing_attempt_exists(path):
750
- raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
751
- try:
752
- require_regular_landing_attempt(path)
753
- document = json.loads(path.read_text(encoding="utf-8"))
754
- payload = {
755
- key: document[key]
756
- for key in landing_attempt_keys(document["contractVersion"])
757
- }
758
- digest = document["sha256"]
759
- metadata = os.lstat(worktree)
760
- branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
761
- except LifecycleError:
762
- raise
763
- except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
764
- raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
765
- if (
766
- payload["contractVersion"] not in {1, 2}
767
- or payload["worktree"] != str(worktree.resolve())
768
- or payload["branch"] != branch
769
- or not stat.S_ISDIR(metadata.st_mode)
770
- or (payload["rootDevice"], payload["rootInode"])
771
- != (metadata.st_dev, metadata.st_ino)
772
- or payload["state"] not in {"started", "frozen"}
773
- or digest != _baseline_digest(payload)
774
- ):
775
- raise LifecycleError("landing-attempt provenance is incoherent")
776
- archive = path.with_name(
777
- f"{LANDING_ATTEMPT_ARCHIVE_STEM}.v{payload['contractVersion']}"
778
- f".abandoned-{int(time() * 1_000_000)}.json"
779
- )
780
- durable_replace(path, archive, label="archive landing attempt")
781
- return archive
782
-
68
+ assumptions: str
69
+ reasons: tuple[str, ...]
70
+ classification: Any
783
71
 
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.
72
+ @property
73
+ def removable(self) -> bool:
74
+ return not self.reasons
789
75
 
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
76
+ @property
77
+ def scratch_files(self) -> tuple[str, ...]:
78
+ if self.classification is None:
79
+ return ()
80
+ return tuple(entry.path for entry in self.classification.scratch)
877
81
 
878
82
 
879
83
  def collect_facts(cwd: Path) -> RepoFacts:
@@ -942,39 +146,6 @@ def is_tracked(root: Path, relative: str) -> bool:
942
146
  return result.returncode == 0
943
147
 
944
148
 
945
- def cleanup_assessment(
946
- profile: WorktreeProfile,
947
- main: Path,
948
- target: Path,
949
- merge_target: str | None = None,
950
- pr_state: str = "none",
951
- verified_scratch_files: tuple[str, ...] = (),
952
- verified_scratch_evidence: tuple[dict[str, Any], ...] = (),
953
- ) -> CleanupAssessment:
954
- assessment = classify_cleanup(
955
- profile,
956
- collect_cleanup_facts(
957
- main,
958
- target,
959
- merge_target=merge_target,
960
- pr_state=pr_state,
961
- ),
962
- verified_scratch_files=verified_scratch_files,
963
- )
964
- try:
965
- return bind_cleanup_scratch_evidence(
966
- profile,
967
- assessment,
968
- verified_scratch_evidence,
969
- require_generator_evidence=True,
970
- )
971
- except LifecycleError as error:
972
- return replace(
973
- assessment,
974
- reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
975
- )
976
-
977
-
978
149
  def collect_cleanup_facts(
979
150
  main: Path,
980
151
  target: Path,
@@ -982,38 +153,21 @@ def collect_cleanup_facts(
982
153
  merge_target: str | None = None,
983
154
  pr_state: str = "none",
984
155
  ) -> CleanupFacts:
156
+ """Read every fact a removal verdict needs, deciding nothing.
157
+
158
+ The file taxonomy comes from `classify.assess`, which is also the object the
159
+ removal step consumes — preview and action can never disagree.
160
+ """
985
161
  worktree = target.resolve()
986
- root_metadata = worktree.stat()
987
162
  branch = run(
988
163
  ["git", "-C", str(worktree), "branch", "--show-current"],
989
164
  cwd=main,
990
165
  check=False,
991
166
  ).stdout.strip()
992
- tracked = set(run(
993
- ["git", "-C", str(worktree), "diff", "--name-only"],
994
- cwd=main,
995
- check=False,
996
- ).stdout.splitlines())
997
- tracked.update(run(
998
- ["git", "-C", str(worktree), "diff", "--cached", "--name-only"],
999
- cwd=main,
1000
- check=False,
1001
- ).stdout.splitlines())
1002
- untracked = set(run(
1003
- ["git", "-C", str(worktree), "ls-files", "--others", "--exclude-standard"],
1004
- cwd=main,
1005
- check=False,
1006
- ).stdout.splitlines())
1007
- untracked.update(run(
1008
- [
1009
- "git", "-C", str(worktree), "ls-files", "--others", "--ignored",
1010
- "--exclude-standard",
1011
- ],
1012
- cwd=main,
1013
- check=False,
1014
- ).stdout.splitlines())
1015
- # ANNAHMEN.md is governed separately: its bytes are returned before removal.
1016
- untracked.discard("ANNAHMEN.md")
167
+ try:
168
+ classification = assess(worktree, main)
169
+ except ClassificationError as error:
170
+ raise LifecycleError(str(error)) from error
1017
171
  merged = False
1018
172
  if branch:
1019
173
  main_branch = merge_target or run(
@@ -1026,6 +180,7 @@ def collect_cleanup_facts(
1026
180
  cwd=main,
1027
181
  check=False,
1028
182
  ).returncode == 0
183
+ # ANNAHMEN.md is governed separately: its bytes are returned before removal.
1029
184
  assumptions_path = worktree / "ANNAHMEN.md"
1030
185
  assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
1031
186
  return CleanupFacts(
@@ -1033,22 +188,14 @@ def collect_cleanup_facts(
1033
188
  branch=branch,
1034
189
  registered=worktree in registered_worktrees(main),
1035
190
  is_main=worktree == main.resolve(),
1036
- tracked_files=tuple(sorted(tracked)),
1037
- untracked_files=tuple(sorted(untracked)),
191
+ classification=classification,
1038
192
  merged=merged,
1039
193
  pr_state=pr_state,
1040
194
  assumptions=assumptions,
1041
- root_device=root_metadata.st_dev,
1042
- root_inode=root_metadata.st_ino,
1043
195
  )
1044
196
 
1045
197
 
1046
- def classify_cleanup(
1047
- profile: WorktreeProfile,
1048
- facts: CleanupFacts,
1049
- *,
1050
- verified_scratch_files: tuple[str, ...] = (),
1051
- ) -> CleanupAssessment:
198
+ def classify_cleanup(profile: WorktreeProfile, facts: CleanupFacts) -> CleanupAssessment:
1052
199
  reasons = []
1053
200
  if not facts.registered:
1054
201
  reasons.append("not a registered worktree")
@@ -1056,27 +203,8 @@ def classify_cleanup(
1056
203
  reasons.append("detached or unreadable branch")
1057
204
  if facts.branch in profile.protected_branches or facts.is_main:
1058
205
  reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
1059
- verified = set(verified_scratch_files)
1060
- missing_verified = sorted(verified.difference(facts.untracked_files))
1061
- if missing_verified:
1062
- reasons.append(
1063
- "verified scratch evidence no longer matches inventory: "
1064
- + ", ".join(missing_verified)
1065
- )
1066
- scratch = sorted(
1067
- path for path in facts.untracked_files
1068
- if (
1069
- path in verified
1070
- or any(path_glob_matches(path, pattern) for pattern in profile.scratch_patterns)
1071
- )
1072
- )
1073
- non_scratch = sorted(set(facts.untracked_files).difference(scratch))
1074
- if facts.tracked_files:
1075
- reasons.append(
1076
- f"dirty worktree: tracked modifications: {', '.join(facts.tracked_files)}"
1077
- )
1078
- if non_scratch:
1079
- reasons.append(f"dirty worktree: untracked non-scratch: {', '.join(non_scratch)}")
206
+ if facts.classification is not None and facts.classification.blocks:
207
+ reasons.append(render_report(facts.classification))
1080
208
  if facts.pr_state == "open":
1081
209
  reasons.append("open PR")
1082
210
  if (
@@ -1090,518 +218,8 @@ def classify_cleanup(
1090
218
  facts.branch,
1091
219
  facts.assumptions,
1092
220
  tuple(reasons),
1093
- facts.root_device,
1094
- facts.root_inode,
1095
- tuple(scratch),
1096
- )
1097
-
1098
-
1099
- @contextmanager
1100
- def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
1101
- """Open the assessed worktree root without following a replacement symlink."""
1102
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1103
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1104
- descriptor = None
1105
- try:
1106
- descriptor = os.open(root, directory_flags | no_follow)
1107
- metadata = os.fstat(descriptor)
1108
- if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
1109
- raise LifecycleError("worktree root changed before removal")
1110
- yield descriptor
1111
- except OSError as error:
1112
- raise LifecycleError("worktree root changed before removal") from error
1113
- finally:
1114
- if descriptor is not None:
1115
- os.close(descriptor)
1116
-
1117
-
1118
- def remove_contained_regular(
1119
- root_descriptor: int,
1120
- relative: str,
1121
- expected_identity: dict[str, Any] | None = None,
1122
- ) -> None:
1123
- """Delete one exact assessed regular file without following path symlinks."""
1124
- path = PurePosixPath(relative)
1125
- if path.is_absolute() or not path.parts or any(
1126
- part in {"", ".", ".."} for part in path.parts
1127
- ):
1128
- raise LifecycleError(f"unsafe scratch path: {relative}")
1129
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1130
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1131
- descriptors = []
1132
- try:
1133
- current = root_descriptor
1134
- for component in path.parts[:-1]:
1135
- current = os.open(
1136
- component,
1137
- directory_flags | no_follow,
1138
- dir_fd=current,
1139
- )
1140
- descriptors.append(current)
1141
- initial_metadata = os.stat(
1142
- path.name,
1143
- dir_fd=current,
1144
- follow_symlinks=False,
1145
- )
1146
- if not stat.S_ISREG(initial_metadata.st_mode):
1147
- raise LifecycleError(f"scratch path is not a regular file: {relative}")
1148
- file_descriptor = os.open(
1149
- path.name,
1150
- os.O_RDONLY | no_follow,
1151
- dir_fd=current,
1152
- )
1153
- try:
1154
- metadata = os.fstat(file_descriptor)
1155
- digest = sha256()
1156
- while chunk := os.read(file_descriptor, 128 * 1024):
1157
- digest.update(chunk)
1158
- finally:
1159
- os.close(file_descriptor)
1160
- if (
1161
- not stat.S_ISREG(metadata.st_mode)
1162
- or (metadata.st_dev, metadata.st_ino)
1163
- != (initial_metadata.st_dev, initial_metadata.st_ino)
1164
- ):
1165
- raise LifecycleError(f"scratch path changed before removal: {relative}")
1166
- identity = {
1167
- "path": relative,
1168
- "device": metadata.st_dev,
1169
- "inode": metadata.st_ino,
1170
- "size": metadata.st_size,
1171
- "sha256": digest.hexdigest(),
1172
- }
1173
- if expected_identity is not None and identity != expected_identity:
1174
- raise LifecycleError(f"scratch path identity changed: {relative}")
1175
- latest = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1176
- if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino):
1177
- raise LifecycleError(f"scratch path changed before removal: {relative}")
1178
- os.unlink(path.name, dir_fd=current)
1179
- except OSError as error:
1180
- raise LifecycleError(f"scratch path changed before removal: {relative}") from error
1181
- finally:
1182
- for descriptor in reversed(descriptors):
1183
- os.close(descriptor)
1184
-
1185
-
1186
- def remove_authorized_scratch(
1187
- profile: WorktreeProfile,
1188
- root_descriptor: int,
1189
- scratch_files: tuple[str, ...] | list[str],
1190
- verified_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
1191
- ) -> None:
1192
- """Remove profile scratch or exact generator evidence without weakening overlaps."""
1193
- evidence_by_path: dict[str, dict[str, Any]] = {}
1194
- for item in verified_evidence:
1195
- relative = item.get("path")
1196
- if not isinstance(relative, str) or relative in evidence_by_path:
1197
- raise LifecycleError("scratch evidence is incoherent")
1198
- evidence_by_path[relative] = item
1199
- unexpected = sorted(set(evidence_by_path).difference(scratch_files))
1200
- if unexpected:
1201
- raise LifecycleError(
1202
- "scratch evidence is outside the assessed inventory: "
1203
- + ", ".join(unexpected)
1204
- )
1205
- for relative in scratch_files:
1206
- generated = any(
1207
- path_glob_matches(relative, pattern)
1208
- for pattern in profile.landing_generated_artifact_patterns
1209
- )
1210
- profile_authorized = any(
1211
- path_glob_matches(relative, pattern)
1212
- for pattern in profile.scratch_patterns
1213
- )
1214
- expected = evidence_by_path.get(relative)
1215
- if not generated and not profile_authorized:
1216
- raise LifecycleError(
1217
- f"canonical cleanup policy does not authorize scratch: {relative}"
1218
- )
1219
- if generated and expected is None:
1220
- raise LifecycleError(
1221
- f"landing-generated scratch evidence is missing: {relative}"
1222
- )
1223
- if generated and expected.get("kind") == "symlink":
1224
- raise LifecycleError(
1225
- f"landing-generated scratch path is not a regular file: {relative}"
1226
- )
1227
- if profile_authorized and expected is None:
1228
- raise LifecycleError(f"profile scratch evidence is missing: {relative}")
1229
- if not profile_authorized and expected is None:
1230
- raise LifecycleError(f"verified scratch evidence is missing: {relative}")
1231
- for relative in scratch_files:
1232
- expected = evidence_by_path[relative]
1233
- if expected.get("kind") == "symlink":
1234
- remove_contained_untracked(
1235
- root_descriptor,
1236
- expected,
1237
- require_contained_symlink_target=True,
1238
- )
1239
- else:
1240
- remove_contained_regular(
1241
- root_descriptor,
1242
- relative,
1243
- expected_identity=expected,
1244
- )
1245
-
1246
-
1247
- def bind_cleanup_scratch_evidence(
1248
- profile: WorktreeProfile,
1249
- assessment: CleanupAssessment,
1250
- verified_generator_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
1251
- *,
1252
- require_generator_evidence: bool = False,
1253
- ) -> CleanupAssessment:
1254
- """Freeze identity for every assessed scratch path at its authority boundary."""
1255
- evidence_by_path = {
1256
- item.get("path"): item
1257
- for item in verified_generator_evidence
1258
- if isinstance(item, dict) and isinstance(item.get("path"), str)
1259
- }
1260
- if len(evidence_by_path) != len(verified_generator_evidence):
1261
- raise LifecycleError("scratch evidence is incoherent")
1262
- unauthorized_evidence = sorted(
1263
- relative
1264
- for relative in evidence_by_path
1265
- if not any(
1266
- path_glob_matches(relative, pattern)
1267
- for pattern in profile.landing_generated_artifact_patterns
1268
- )
221
+ facts.classification,
1269
222
  )
1270
- if unauthorized_evidence:
1271
- raise LifecycleError(
1272
- "generator evidence is outside canonical landing policy: "
1273
- + ", ".join(unauthorized_evidence)
1274
- )
1275
- scratch = set(assessment.scratch_files)
1276
- if not set(evidence_by_path).issubset(scratch):
1277
- raise LifecycleError("scratch evidence is outside the assessed inventory")
1278
- profile_only = [
1279
- relative
1280
- for relative in assessment.scratch_files
1281
- if not any(
1282
- path_glob_matches(relative, pattern)
1283
- for pattern in profile.landing_generated_artifact_patterns
1284
- )
1285
- ]
1286
- missing_generator = [
1287
- relative
1288
- for relative in assessment.scratch_files
1289
- if any(
1290
- path_glob_matches(relative, pattern)
1291
- for pattern in profile.landing_generated_artifact_patterns
1292
- )
1293
- and relative not in evidence_by_path
1294
- ]
1295
- if missing_generator and require_generator_evidence:
1296
- raise LifecycleError(
1297
- "landing-generated scratch evidence is missing: "
1298
- + ", ".join(missing_generator)
1299
- )
1300
- with verified_worktree_root(
1301
- assessment.worktree,
1302
- assessment.root_device,
1303
- assessment.root_inode,
1304
- ) as descriptor:
1305
- for relative in profile_only:
1306
- evidence_by_path[relative] = contained_profile_scratch_identity(
1307
- descriptor, relative
1308
- )
1309
- return replace(
1310
- assessment,
1311
- scratch_evidence=tuple(
1312
- evidence_by_path[relative]
1313
- for relative in assessment.scratch_files
1314
- if relative in evidence_by_path
1315
- ),
1316
- )
1317
-
1318
-
1319
- def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str, Any]:
1320
- """Read one exact regular file identity without following path symlinks."""
1321
- path = PurePosixPath(relative)
1322
- if path.is_absolute() or not path.parts or any(
1323
- part in {"", ".", ".."} for part in path.parts
1324
- ):
1325
- raise LifecycleError(f"unsafe scratch path: {relative}")
1326
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1327
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1328
- descriptors = []
1329
- try:
1330
- current = root_descriptor
1331
- for component in path.parts[:-1]:
1332
- current = os.open(
1333
- component,
1334
- directory_flags | no_follow,
1335
- dir_fd=current,
1336
- )
1337
- descriptors.append(current)
1338
- initial = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1339
- if not stat.S_ISREG(initial.st_mode):
1340
- raise LifecycleError(
1341
- f"scratch path is not a regular file: {relative}"
1342
- )
1343
- file_descriptor = os.open(
1344
- path.name,
1345
- os.O_RDONLY | no_follow,
1346
- dir_fd=current,
1347
- )
1348
- try:
1349
- metadata = os.fstat(file_descriptor)
1350
- if (
1351
- not stat.S_ISREG(metadata.st_mode)
1352
- or (metadata.st_dev, metadata.st_ino)
1353
- != (initial.st_dev, initial.st_ino)
1354
- ):
1355
- raise LifecycleError(
1356
- f"scratch path changed before inspection: {relative}"
1357
- )
1358
- digest = sha256()
1359
- while chunk := os.read(file_descriptor, 128 * 1024):
1360
- digest.update(chunk)
1361
- finally:
1362
- os.close(file_descriptor)
1363
- return {
1364
- "path": relative,
1365
- "device": metadata.st_dev,
1366
- "inode": metadata.st_ino,
1367
- "size": metadata.st_size,
1368
- "sha256": digest.hexdigest(),
1369
- }
1370
- except OSError as error:
1371
- raise LifecycleError(f"scratch path changed before inspection: {relative}") from error
1372
- finally:
1373
- for descriptor in reversed(descriptors):
1374
- os.close(descriptor)
1375
-
1376
-
1377
- def contained_untracked_identity(
1378
- root_descriptor: int,
1379
- relative: str,
1380
- *,
1381
- require_contained_symlink_target: bool = False,
1382
- ) -> dict[str, Any]:
1383
- """Read exact regular/symlink identity without following any symlink."""
1384
- path = PurePosixPath(relative)
1385
- if path.is_absolute() or not path.parts or any(
1386
- part in {"", ".", ".."} for part in path.parts
1387
- ):
1388
- raise LifecycleError(f"unsafe recovery path: {relative}")
1389
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1390
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1391
- descriptors = []
1392
- try:
1393
- current = root_descriptor
1394
- for component in path.parts[:-1]:
1395
- current = os.open(
1396
- component,
1397
- directory_flags | no_follow,
1398
- dir_fd=current,
1399
- )
1400
- descriptors.append(current)
1401
- metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1402
- common = {
1403
- "path": relative,
1404
- "device": metadata.st_dev,
1405
- "inode": metadata.st_ino,
1406
- "size": metadata.st_size,
1407
- }
1408
- if stat.S_ISLNK(metadata.st_mode):
1409
- target_text = os.readlink(path.name, dir_fd=current)
1410
- target = target_text.encode(
1411
- "utf-8", errors="surrogateescape"
1412
- )
1413
- identity = {
1414
- **common,
1415
- "kind": "symlink",
1416
- "sha256": sha256(target).hexdigest(),
1417
- }
1418
- if require_contained_symlink_target:
1419
- target_metadata = contained_symlink_target_identity(
1420
- root_descriptor,
1421
- relative,
1422
- target_text,
1423
- )
1424
- identity.update({
1425
- "targetDevice": target_metadata.st_dev,
1426
- "targetInode": target_metadata.st_ino,
1427
- })
1428
- return identity
1429
- if not stat.S_ISREG(metadata.st_mode):
1430
- raise LifecycleError(f"unsupported recovery path type: {relative}")
1431
- file_descriptor = os.open(
1432
- path.name,
1433
- os.O_RDONLY | no_follow,
1434
- dir_fd=current,
1435
- )
1436
- try:
1437
- opened = os.fstat(file_descriptor)
1438
- if (opened.st_dev, opened.st_ino) != (
1439
- metadata.st_dev, metadata.st_ino
1440
- ):
1441
- raise LifecycleError(
1442
- f"recovery path changed before inspection: {relative}"
1443
- )
1444
- digest = sha256()
1445
- while chunk := os.read(file_descriptor, 128 * 1024):
1446
- digest.update(chunk)
1447
- finally:
1448
- os.close(file_descriptor)
1449
- return {**common, "kind": "regular", "sha256": digest.hexdigest()}
1450
- except OSError as error:
1451
- raise LifecycleError(
1452
- f"recovery path changed before inspection: {relative}"
1453
- ) from error
1454
- finally:
1455
- for descriptor in reversed(descriptors):
1456
- os.close(descriptor)
1457
-
1458
-
1459
- def contained_profile_scratch_identity(
1460
- root_descriptor: int,
1461
- relative: str,
1462
- ) -> dict[str, Any]:
1463
- """Freeze regular scratch unchanged, or one contained symlink and its target."""
1464
- identity = contained_untracked_identity(
1465
- root_descriptor,
1466
- relative,
1467
- require_contained_symlink_target=True,
1468
- )
1469
- if identity["kind"] == "regular":
1470
- return {
1471
- key: value
1472
- for key, value in identity.items()
1473
- if key != "kind"
1474
- }
1475
- return identity
1476
-
1477
-
1478
- def _relative_target_parts(
1479
- base_parts: list[str],
1480
- target: str,
1481
- relative: str,
1482
- ) -> list[str]:
1483
- target_path = PurePosixPath(target)
1484
- if target_path.is_absolute():
1485
- raise LifecycleError(f"scratch symlink target is absolute: {relative}")
1486
- resolved = list(base_parts)
1487
- for component in target_path.parts:
1488
- if component in {"", "."}:
1489
- continue
1490
- if component == "..":
1491
- if not resolved:
1492
- raise LifecycleError(
1493
- f"scratch symlink target escapes worktree: {relative}"
1494
- )
1495
- resolved.pop()
1496
- continue
1497
- resolved.append(component)
1498
- return resolved
1499
-
1500
-
1501
- def contained_symlink_target_identity(
1502
- root_descriptor: int,
1503
- relative: str,
1504
- target: str,
1505
- ) -> os.stat_result:
1506
- """Resolve one symlink target beneath the open root, rejecting every escape."""
1507
- link = PurePosixPath(relative)
1508
- pending = _relative_target_parts(list(link.parts[:-1]), target, relative)
1509
- resolved: list[str] = []
1510
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1511
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1512
- symlink_count = 0
1513
- try:
1514
- if not pending:
1515
- return os.fstat(root_descriptor)
1516
- while pending:
1517
- component = pending.pop(0)
1518
- descriptors = []
1519
- try:
1520
- parent = root_descriptor
1521
- for ancestor in resolved:
1522
- parent = os.open(
1523
- ancestor,
1524
- directory_flags | no_follow,
1525
- dir_fd=parent,
1526
- )
1527
- descriptors.append(parent)
1528
- metadata = os.stat(
1529
- component,
1530
- dir_fd=parent,
1531
- follow_symlinks=False,
1532
- )
1533
- if stat.S_ISLNK(metadata.st_mode):
1534
- symlink_count += 1
1535
- if symlink_count > 40:
1536
- raise OSError("too many symbolic links")
1537
- nested_target = os.readlink(component, dir_fd=parent)
1538
- pending = _relative_target_parts(
1539
- resolved,
1540
- nested_target,
1541
- relative,
1542
- ) + pending
1543
- resolved = []
1544
- if not pending:
1545
- return os.fstat(root_descriptor)
1546
- continue
1547
- if pending and not stat.S_ISDIR(metadata.st_mode):
1548
- raise OSError("symlink target parent is not a directory")
1549
- resolved.append(component)
1550
- if not pending:
1551
- return metadata
1552
- finally:
1553
- for descriptor in reversed(descriptors):
1554
- os.close(descriptor)
1555
- except LifecycleError:
1556
- raise
1557
- except OSError as error:
1558
- raise LifecycleError(
1559
- f"scratch symlink target is dangling or changed: {relative}"
1560
- ) from error
1561
- raise LifecycleError(f"scratch symlink target is dangling or changed: {relative}")
1562
-
1563
-
1564
- def remove_contained_untracked(
1565
- root_descriptor: int,
1566
- expected_identity: dict[str, Any],
1567
- *,
1568
- require_contained_symlink_target: bool = False,
1569
- ) -> None:
1570
- """Unlink one frozen regular file or symlink if its identity still matches."""
1571
- relative = expected_identity.get("path")
1572
- if not isinstance(relative, str):
1573
- raise LifecycleError("recovery path evidence is incoherent")
1574
- current = contained_untracked_identity(
1575
- root_descriptor,
1576
- relative,
1577
- require_contained_symlink_target=require_contained_symlink_target,
1578
- )
1579
- if current != expected_identity:
1580
- raise LifecycleError(f"recovery path identity changed: {relative}")
1581
- path = PurePosixPath(relative)
1582
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1583
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1584
- descriptors = []
1585
- try:
1586
- parent = root_descriptor
1587
- for component in path.parts[:-1]:
1588
- parent = os.open(
1589
- component,
1590
- directory_flags | no_follow,
1591
- dir_fd=parent,
1592
- )
1593
- descriptors.append(parent)
1594
- latest = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
1595
- if (latest.st_dev, latest.st_ino) != (
1596
- expected_identity["device"], expected_identity["inode"]
1597
- ):
1598
- raise LifecycleError(f"recovery path changed before removal: {relative}")
1599
- os.unlink(path.name, dir_fd=parent)
1600
- except OSError as error:
1601
- raise LifecycleError(f"recovery path changed before removal: {relative}") from error
1602
- finally:
1603
- for descriptor in reversed(descriptors):
1604
- os.close(descriptor)
1605
223
 
1606
224
 
1607
225
  @dataclass(frozen=True)
@@ -1637,6 +255,7 @@ class SweepFactRow:
1637
255
  merged_into_main: bool
1638
256
  last_commit_age_seconds: int
1639
257
  cleanup: CleanupFacts | None = None
258
+ cleanup_error: str = ""
1640
259
 
1641
260
 
1642
261
  @dataclass(frozen=True)
@@ -1670,6 +289,23 @@ def _worktree_branches(main: Path) -> tuple[dict[str, Path], tuple[Path, ...]]:
1670
289
  return linked, tuple(detached)
1671
290
 
1672
291
 
292
+ def _cleanup_facts_or_error(
293
+ main: Path,
294
+ path: Path,
295
+ *,
296
+ merge_target: str,
297
+ pr_state: str,
298
+ ) -> tuple[CleanupFacts | None, str]:
299
+ """A single unreadable worktree must not abort the read-only inventory."""
300
+ try:
301
+ facts = collect_cleanup_facts(
302
+ main, path, merge_target=merge_target, pr_state=pr_state
303
+ )
304
+ except LifecycleError as error:
305
+ return None, str(error)
306
+ return facts, ""
307
+
308
+
1673
309
  def collect_sweep_facts(
1674
310
  profile: WorktreeProfile,
1675
311
  main: Path,
@@ -1702,18 +338,21 @@ def collect_sweep_facts(
1702
338
  cwd=main,
1703
339
  check=False,
1704
340
  ).returncode == 0
341
+ cleanup, cleanup_error = (
342
+ _cleanup_facts_or_error(
343
+ main, path, merge_target=main_branch, pr_state=pr_state
344
+ )
345
+ if path is not None
346
+ else (None, "")
347
+ )
1705
348
  rows.append(SweepFactRow(
1706
349
  path=path,
1707
350
  branch=branch,
1708
351
  pr_state=pr_state,
1709
352
  merged_into_main=merged,
1710
353
  last_commit_age_seconds=max(0, timestamp - int(commit_time)),
1711
- cleanup=collect_cleanup_facts(
1712
- main,
1713
- path,
1714
- merge_target=main_branch,
1715
- pr_state=pr_state,
1716
- ) if path is not None else None,
354
+ cleanup=cleanup,
355
+ cleanup_error=cleanup_error,
1717
356
  ))
1718
357
  for path in detached:
1719
358
  commit_time = run(
@@ -1759,7 +398,7 @@ def classify_sweep(profile: WorktreeProfile, facts: SweepFacts) -> SweepReport:
1759
398
  scratch = assessment.scratch_files
1760
399
  assumptions = assessment.assumptions
1761
400
  elif fact.path is not None:
1762
- reasons = ("detached or unreadable branch",)
401
+ reasons = (fact.cleanup_error or "detached or unreadable branch",)
1763
402
  scratch = ()
1764
403
  assumptions = ""
1765
404
  else: