@ikon85/agent-workflow-kit 0.43.0 → 0.44.1

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 (127) 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/skill-manifest.json +1 -1
  22. package/.claude/skills/to-issues/SKILL.md +43 -0
  23. package/.claude/skills/wrapup/SKILL.md +98 -54
  24. package/README.md +254 -7
  25. package/agent-workflow-kit.package.json +43 -43
  26. package/package.json +16 -12
  27. package/scripts/marker_lib.py +3 -0
  28. package/scripts/profile_globs.py +26 -45
  29. package/scripts/release-delta-guard.mjs +26 -9
  30. package/scripts/worktree-lifecycle/README.md +57 -160
  31. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  32. package/scripts/worktree-lifecycle/classify.py +480 -0
  33. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  34. package/scripts/worktree-lifecycle/core.py +68 -1429
  35. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  36. package/scripts/worktree-lifecycle/profile.py +46 -29
  37. package/scripts/worktree-lifecycle/setup.py +7 -19
  38. package/scripts/wrapup-land.py +1292 -545
  39. package/src/consumer-migrations.json +1 -16
  40. package/src/lib/bundle.mjs +31 -1
  41. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  42. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  43. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  44. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  45. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  46. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  47. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  48. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  49. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  50. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  51. package/docs/agents/board-sync.md +0 -74
  52. package/docs/agents/code-review.md +0 -14
  53. package/docs/agents/workflow-capabilities.json +0 -28
  54. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  55. package/docs/research/benchlm-routing-source.md +0 -198
  56. package/docs/research/consumer-owned-protocol-files.md +0 -238
  57. package/docs/research/frontend-agent-benchmarks.md +0 -282
  58. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  59. package/docs/research/provider-neutral-agent-routing.md +0 -207
  60. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  61. package/docs/research/wave-43-script-hook-census.md +0 -128
  62. package/scripts/build-kit.mjs +0 -117
  63. package/scripts/build-kit.test.mjs +0 -207
  64. package/scripts/census/census.test.mjs +0 -252
  65. package/scripts/census/state.test.mjs +0 -15
  66. package/scripts/census/transaction.test.mjs +0 -103
  67. package/scripts/census-contract.test.mjs +0 -227
  68. package/scripts/check-kit-staleness.mjs +0 -45
  69. package/scripts/check-kit-staleness.test.mjs +0 -19
  70. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  71. package/scripts/codex-exec.test.mjs +0 -871
  72. package/scripts/grill-census-wiring-guard.mjs +0 -87
  73. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  74. package/scripts/kit-release.test.mjs +0 -273
  75. package/scripts/kit-update-pr.test.mjs +0 -151
  76. package/scripts/lib/audit-refs.mjs +0 -146
  77. package/scripts/lib/scrub.mjs +0 -113
  78. package/scripts/lib/scrub.test.mjs +0 -187
  79. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  80. package/scripts/portability_profile_scan.py +0 -53
  81. package/scripts/release-delta-guard.test.mjs +0 -174
  82. package/scripts/release-parity.test.mjs +0 -90
  83. package/scripts/release-state.test.mjs +0 -360
  84. package/scripts/test_anchor_table.py +0 -69
  85. package/scripts/test_board_bootstrap.py +0 -348
  86. package/scripts/test_board_sync.py +0 -208
  87. package/scripts/test_board_sync_create_idempotency.py +0 -117
  88. package/scripts/test_board_sync_wave_title.py +0 -120
  89. package/scripts/test_census_backstop.py +0 -712
  90. package/scripts/test_census_forward_contract.py +0 -91
  91. package/scripts/test_census_update_contract.test.mjs +0 -454
  92. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  93. package/scripts/test_dist_kit_smoke.py +0 -220
  94. package/scripts/test_drift_guard_diagnostics.py +0 -295
  95. package/scripts/test_issue_claim_contract.py +0 -169
  96. package/scripts/test_kit_docs_language_census.py +0 -178
  97. package/scripts/test_marker_lib.py +0 -162
  98. package/scripts/test_orchestrate_wave_contract.py +0 -381
  99. package/scripts/test_pr_body_check.py +0 -264
  100. package/scripts/test_profile_globs.py +0 -280
  101. package/scripts/test_program_planning_contract.py +0 -133
  102. package/scripts/test_release_authorization_contract.py +0 -101
  103. package/scripts/test_render_anchor.py +0 -267
  104. package/scripts/test_retro_wrapup_contract.py +0 -153
  105. package/scripts/test_skill_code_review_seed.py +0 -89
  106. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  107. package/scripts/test_skill_frontmatter_lint.py +0 -234
  108. package/scripts/test_skill_gh_lint.py +0 -124
  109. package/scripts/test_skill_language_census.py +0 -254
  110. package/scripts/test_skill_optional_readiness.py +0 -171
  111. package/scripts/test_skill_portability_lint.py +0 -866
  112. package/scripts/test_skill_precommit_template.py +0 -30
  113. package/scripts/test_skill_publish_audit.py +0 -358
  114. package/scripts/test_skill_readiness_contract.py +0 -264
  115. package/scripts/test_skill_readiness_preflight.py +0 -180
  116. package/scripts/test_skill_required_readiness.py +0 -233
  117. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  118. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  119. package/scripts/test_skill_stale_name_lint.py +0 -126
  120. package/scripts/test_skill_surface_refs.py +0 -300
  121. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  122. package/scripts/test_tdd_contract.py +0 -78
  123. package/scripts/test_worktree_ignore_seed.py +0 -320
  124. package/scripts/test_worktree_setup_base_guard.py +0 -140
  125. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  126. package/scripts/test_wrapup_land.py +0 -428
  127. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,1737 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Wrapup must reuse the shipped Worktree Lifecycle cleanup assessment."""
3
-
4
- import importlib.util
5
- import json
6
- import os
7
- import subprocess
8
- import tempfile
9
- import unittest
10
- from pathlib import Path
11
- from types import SimpleNamespace
12
- from unittest.mock import patch
13
-
14
- REPO = Path(__file__).resolve().parent.parent
15
- WRAPUP = REPO / "scripts/wrapup-land.py"
16
- SETUP = REPO / "scripts/worktree-lifecycle/setup.py"
17
-
18
-
19
- def load_wrapup():
20
- spec = importlib.util.spec_from_file_location("wrapup_land_worktree_contract", WRAPUP)
21
- module = importlib.util.module_from_spec(spec)
22
- spec.loader.exec_module(module)
23
- return module
24
-
25
-
26
- def command(args, cwd):
27
- result = subprocess.run(
28
- args,
29
- cwd=cwd,
30
- capture_output=True,
31
- text=True,
32
- )
33
- if result.returncode != 0:
34
- raise AssertionError(
35
- f"{' '.join(args)} failed ({result.returncode}): "
36
- f"{(result.stderr or result.stdout).strip()}"
37
- )
38
- return result
39
-
40
-
41
- def create_merged_worktree(
42
- root: Path,
43
- *,
44
- setup_steps: list[dict] | None = None,
45
- scratch_patterns: list[str] | None = None,
46
- ) -> tuple[Path, Path]:
47
- remote = root / "remote.git"
48
- main = root / "main"
49
- command(["git", "init", "--bare", str(remote)], root)
50
- command(["git", "init", "-b", "main", str(main)], root)
51
- command(["git", "config", "user.name", "Test"], main)
52
- command(["git", "config", "user.email", "test@example.invalid"], main)
53
- (main / ".gitignore").write_text(
54
- ".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\nnode_modules/\n",
55
- encoding="utf-8",
56
- )
57
- profile = main / "docs/agents/workflow-capabilities.json"
58
- profile.parent.mkdir(parents=True)
59
- profile.write_text(json.dumps({
60
- "worktreeLifecycle": {
61
- "enabled": True,
62
- "worktreeRoot": ".worktrees",
63
- "branchTemplate": "{type}/{issue}-{slug}",
64
- "pathTemplate": "{issue}-{slug}",
65
- "mainBranches": ["main"],
66
- "protectedBranches": ["main"],
67
- "scratchPatterns": scratch_patterns or [],
68
- "setupSteps": setup_steps or [],
69
- },
70
- "wrapup": {
71
- "landingGeneratedArtifactPatterns": [
72
- "dist-kit/**",
73
- "**/__pycache__/**",
74
- ],
75
- },
76
- }), encoding="utf-8")
77
- command(["git", "add", "."], main)
78
- command(["git", "commit", "-m", "seed"], main)
79
- command(["git", "remote", "add", "origin", str(remote)], main)
80
- command(["git", "push", "-u", "origin", "main"], main)
81
- command([
82
- os.sys.executable,
83
- str(SETUP),
84
- "--profile",
85
- str(profile),
86
- "--base",
87
- "origin/main",
88
- "268",
89
- "cleanup",
90
- "fix",
91
- ], main)
92
- worktree = main / ".worktrees/268-cleanup"
93
- command(["git", "rev-parse", "--verify", "HEAD"], worktree)
94
- (worktree / "change.txt").write_text("landed\n", encoding="utf-8")
95
- command(["git", "add", "change.txt"], worktree)
96
- command(["git", "commit", "-m", "change"], worktree)
97
- command(["git", "merge", "--ff-only", "fix/268-cleanup"], main)
98
- command(["git", "push", "origin", "main"], main)
99
- return main, worktree
100
-
101
-
102
- def create_real_kit_merged_worktree(root: Path) -> tuple[Path, Path]:
103
- main = root / "main"
104
- remote = root / "remote.git"
105
- command(["git", "clone", "--no-local", str(REPO), str(main)], root)
106
- command(["git", "config", "user.name", "Test"], main)
107
- command(["git", "config", "user.email", "test@example.invalid"], main)
108
- command(["git", "checkout", "-B", "main"], main)
109
- command(["git", "remote", "remove", "origin"], main)
110
- command(["git", "init", "--bare", str(remote)], root)
111
- command(["git", "remote", "add", "origin", str(remote)], main)
112
-
113
- ignore = main / ".gitignore"
114
- ignored = ignore.read_text(encoding="utf-8")
115
- required_ignores = [
116
- ".worktrees/",
117
- "dist-kit/",
118
- "**/__pycache__/",
119
- ".claude/logs/",
120
- "PLAN.md",
121
- ]
122
- missing = [pattern for pattern in required_ignores if pattern not in ignored.splitlines()]
123
- if missing:
124
- ignore.write_text(
125
- ignored.rstrip("\n") + "\n" + "\n".join(missing) + "\n",
126
- encoding="utf-8",
127
- )
128
-
129
- profile_path = main / "docs/agents/workflow-capabilities.json"
130
- profile = json.loads(profile_path.read_text(encoding="utf-8"))
131
- lifecycle = profile.setdefault("worktreeLifecycle", {})
132
- lifecycle.update({
133
- "enabled": True,
134
- "worktreeRoot": ".worktrees",
135
- "branchTemplate": "{type}/{issue}-{slug}",
136
- "pathTemplate": "{issue}-{slug}",
137
- "mainBranches": ["main"],
138
- "protectedBranches": ["main"],
139
- "scratchPatterns": ["PLAN.md", ".claude/logs/**"],
140
- "setupSteps": [],
141
- })
142
- profile["wrapup"] = {
143
- "landingGeneratedArtifactPatterns": [
144
- "dist-kit/**",
145
- "**/__pycache__/**",
146
- ],
147
- }
148
- profile_path.write_text(
149
- json.dumps(profile, indent=2) + "\n",
150
- encoding="utf-8",
151
- )
152
- command(["git", "add", ".gitignore", str(profile_path.relative_to(main))], main)
153
- command(["git", "commit", "-m", "configure lifecycle fixture"], main)
154
- command(["git", "push", "-u", "origin", "main"], main)
155
-
156
- command([
157
- os.sys.executable,
158
- str(SETUP),
159
- "--profile",
160
- str(profile_path),
161
- "--base",
162
- "origin/main",
163
- "268",
164
- "real-generator",
165
- "fix",
166
- ], main)
167
- worktree = main / ".worktrees/268-real-generator"
168
- (worktree / "change.txt").write_text("landed\n", encoding="utf-8")
169
- command(["git", "add", "change.txt"], worktree)
170
- command(["git", "commit", "-m", "change"], worktree)
171
- command(["git", "merge", "--ff-only", "fix/268-real-generator"], main)
172
- command(["git", "push", "origin", "main"], main)
173
- return main, worktree
174
-
175
-
176
- def merged_landing_runner(wrapup):
177
- """Stub the external gh/board calls of an already-MERGED land run."""
178
- real_run = wrapup.run
179
-
180
- def landing_run(args, cwd=None, check=False):
181
- if args[:3] == ["gh", "pr", "view"]:
182
- fields = args[-1]
183
- payload = (
184
- {"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
185
- if "number,state,body" in fields
186
- else {"state": "MERGED"}
187
- )
188
- return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
189
- if args[:3] == ["gh", "issue", "view"]:
190
- return subprocess.CompletedProcess(
191
- args, 0, json.dumps({"state": "CLOSED"}), ""
192
- )
193
- if args[:3] == ["gh", "pr", "list"]:
194
- return subprocess.CompletedProcess(args, 0, "", "")
195
- if args and args[0] == os.sys.executable:
196
- return subprocess.CompletedProcess(args, 0, "", "")
197
- return real_run(args, cwd=cwd, check=check)
198
-
199
- return landing_run
200
-
201
-
202
- def land_args(branch="fix/268-cleanup", **overrides):
203
- values = {
204
- "branch": branch,
205
- "body_file": None,
206
- "title": None,
207
- "anchor": None,
208
- "skip_malformed_drift": False,
209
- "abandon_unfinished_attempt": False,
210
- "recover_canonical_cleanup": False,
211
- }
212
- values.update(overrides)
213
- return SimpleNamespace(**values)
214
-
215
-
216
- def run_land(wrapup, main: Path, args) -> dict:
217
- """Run `land` from the main tree against an already-merged PR."""
218
- previous = Path.cwd()
219
- try:
220
- os.chdir(main)
221
- with (
222
- patch.object(wrapup, "run", side_effect=merged_landing_runner(wrapup)),
223
- patch.object(wrapup, "wait_for_merge_gate", return_value=True),
224
- patch.object(wrapup, "kill_worktree_processes", return_value=[]),
225
- ):
226
- return wrapup.cmd_land(args)
227
- finally:
228
- os.chdir(previous)
229
-
230
-
231
- def rewrite_landing_attempt(core, worktree: Path, mutate) -> Path:
232
- """Rewrite the attempt journal with a coherent digest for a forged payload."""
233
- path = core.artifact_baseline_path(worktree).with_name(core.LANDING_ATTEMPT_FILE)
234
- document = json.loads(path.read_text(encoding="utf-8"))
235
- payload = {key: value for key, value in document.items() if key != "sha256"}
236
- mutate(payload)
237
- path.write_text(
238
- json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
239
- encoding="utf-8",
240
- )
241
- return path
242
-
243
-
244
- def downgrade_landing_attempt_to_v1(core, worktree: Path) -> Path:
245
- """Produce the coherent v1 journal shape a pre-upgrade landing left behind."""
246
- def mutate(payload):
247
- payload.pop("policyDigest", None)
248
- payload["contractVersion"] = 1
249
-
250
- return rewrite_landing_attempt(core, worktree, mutate)
251
-
252
-
253
- class WorktreeCleanupContract(unittest.TestCase):
254
- def test_declared_close_targets_is_pr_body_authority(self):
255
- mod = load_wrapup()
256
- self.assertEqual(mod.declared_close_targets("Part of #320"), [])
257
- self.assertEqual(mod.declared_close_targets("closes #341\ncloses #12"),
258
- ["12", "341"])
259
- self.assertEqual(mod.declared_close_targets("`closes #341`"), [])
260
-
261
- def test_profile_scratch_and_generated_evidence_share_safe_removal_contract(self):
262
- wrapup = load_wrapup()
263
- with tempfile.TemporaryDirectory() as tmp:
264
- main, worktree = create_merged_worktree(
265
- Path(tmp), scratch_patterns=["PLAN.md"]
266
- )
267
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
268
- (worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
269
- generated = worktree / "dist-kit/package.tgz"
270
- generated.parent.mkdir(parents=True)
271
- generated.write_text("generated\n", encoding="utf-8")
272
- evidence = wrapup.landing_verified_scratch_evidence(
273
- str(worktree), str(main)
274
- )
275
- assessment = wrapup.ensure_worktree_removable(
276
- str(worktree),
277
- str(main),
278
- verified_scratch_files=tuple(item["path"] for item in evidence),
279
- verified_scratch_evidence=evidence,
280
- )
281
-
282
- final = wrapup.remove_verified_worktree_scratch(
283
- str(worktree),
284
- str(main),
285
- assessment,
286
- verified_scratch_evidence=evidence,
287
- )
288
-
289
- self.assertFalse(final.reasons)
290
- self.assertFalse((worktree / "PLAN.md").exists())
291
- self.assertFalse(generated.exists())
292
-
293
- def test_profile_scratch_same_path_replacement_is_preserved(self):
294
- wrapup = load_wrapup()
295
- with tempfile.TemporaryDirectory() as tmp:
296
- main, worktree = create_merged_worktree(
297
- Path(tmp), scratch_patterns=["PLAN.md"]
298
- )
299
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
300
- plan = worktree / "PLAN.md"
301
- plan.write_text("assessed\n", encoding="utf-8")
302
- assessment = wrapup.ensure_worktree_removable(
303
- str(worktree), str(main)
304
- )
305
- plan.unlink()
306
- plan.write_text("replacement\n", encoding="utf-8")
307
- with self.assertRaises(wrapup.Stop) as stopped:
308
- wrapup.remove_verified_worktree_scratch(
309
- str(worktree), str(main), assessment
310
- )
311
- self.assertIn("inventory no longer matches preview", stopped.exception.reason)
312
- self.assertEqual(plan.read_text(encoding="utf-8"), "replacement\n")
313
-
314
- def test_missing_generator_identity_is_not_downgraded_to_profile_scratch(self):
315
- wrapup = load_wrapup()
316
- with tempfile.TemporaryDirectory() as tmp:
317
- main, worktree = create_merged_worktree(Path(tmp))
318
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
319
- generated = worktree / "dist-kit/package.tgz"
320
- generated.parent.mkdir(parents=True)
321
- generated.write_text("generated\n", encoding="utf-8")
322
- with self.assertRaises(wrapup.Stop) as stopped:
323
- wrapup.ensure_worktree_removable(
324
- str(worktree),
325
- str(main),
326
- verified_scratch_files=("dist-kit/package.tgz",),
327
- verified_scratch_evidence=(),
328
- )
329
-
330
- self.assertIn("evidence", stopped.exception.detail)
331
- self.assertTrue(generated.exists())
332
-
333
- def test_generator_pattern_overlap_still_requires_exact_evidence(self):
334
- wrapup = load_wrapup()
335
- with tempfile.TemporaryDirectory() as tmp:
336
- main, worktree = create_merged_worktree(
337
- Path(tmp), scratch_patterns=["dist-kit/**"]
338
- )
339
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
340
- generated = worktree / "dist-kit/package.tgz"
341
- generated.parent.mkdir(parents=True)
342
- generated.write_text("generated\n", encoding="utf-8")
343
- with self.assertRaises(wrapup.Stop) as stopped:
344
- wrapup.ensure_worktree_removable(
345
- str(worktree), str(main), verified_scratch_files=()
346
- )
347
- self.assertIn(
348
- "landing-generated scratch evidence is missing",
349
- stopped.exception.detail,
350
- )
351
- self.assertTrue(generated.exists())
352
-
353
- def test_legacy_worktree_gets_conservative_baseline_without_claiming_existing_files(self):
354
- wrapup = load_wrapup()
355
- with tempfile.TemporaryDirectory() as tmp:
356
- main, worktree = create_merged_worktree(Path(tmp))
357
- core = wrapup.load_worktree_cleanup_core()
358
- core.artifact_baseline_path(worktree).unlink()
359
- existing = worktree / "dist-kit/existing.tgz"
360
- existing.parent.mkdir(parents=True)
361
- existing.write_text("consumer\n", encoding="utf-8")
362
- preserved = worktree / "consumer/keep.txt"
363
- preserved.parent.mkdir(parents=True)
364
- preserved.write_text("preserve\n", encoding="utf-8")
365
-
366
- with self.assertRaises(wrapup.Stop) as stopped:
367
- wrapup.landing_start_artifact_inventory(
368
- str(worktree), str(main)
369
- )
370
-
371
- self.assertFalse(core.artifact_baseline_path(worktree).exists())
372
- self.assertIn("consumer-owned", stopped.exception.reason)
373
- self.assertTrue(existing.exists())
374
- self.assertTrue(preserved.exists())
375
-
376
- existing.unlink()
377
- attempt = wrapup.landing_start_artifact_inventory(
378
- str(worktree), str(main)
379
- )
380
- baseline = core.load_artifact_baseline(worktree)
381
- self.assertNotIn("dist-kit/existing.tgz", baseline.initial_ignored_files)
382
- self.assertIn("consumer/keep.txt", baseline.initial_ignored_files)
383
- existing.write_text("landing-generated\n", encoding="utf-8")
384
- evidence = wrapup.landing_verified_scratch_evidence(
385
- str(worktree),
386
- str(main),
387
- expected_baseline_digest=attempt["baselineDigest"],
388
- landing_start_files=tuple(attempt["generatedFiles"]),
389
- )
390
- self.assertEqual(
391
- [item["path"] for item in evidence],
392
- ["dist-kit/existing.tgz"],
393
- )
394
-
395
- def test_preexisting_generated_blocker_does_not_poison_next_attempt(self):
396
- wrapup = load_wrapup()
397
- with tempfile.TemporaryDirectory() as tmp:
398
- main, worktree = create_merged_worktree(Path(tmp))
399
- blocker = worktree / "dist-kit/preexisting.tgz"
400
- blocker.parent.mkdir(parents=True)
401
- blocker.write_text("consumer\n", encoding="utf-8")
402
- core = wrapup.load_worktree_cleanup_core()
403
- attempt_path = core.artifact_baseline_path(worktree).with_name(
404
- core.LANDING_ATTEMPT_FILE
405
- )
406
-
407
- with self.assertRaises(wrapup.Stop):
408
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
409
- self.assertFalse(attempt_path.exists())
410
- blocker.unlink()
411
- attempt = wrapup.landing_start_artifact_inventory(
412
- str(worktree), str(main)
413
- )
414
- self.assertTrue(attempt["newAttempt"])
415
-
416
- def test_candidate_policy_blocks_generated_path_before_canonical_bootstrap(self):
417
- wrapup = load_wrapup()
418
- with tempfile.TemporaryDirectory() as tmp:
419
- main, worktree = create_merged_worktree(Path(tmp))
420
- core = wrapup.load_worktree_cleanup_core()
421
- baseline_path = core.artifact_baseline_path(worktree)
422
- baseline_path.unlink()
423
- canonical_profile = main / "docs/agents/workflow-capabilities.json"
424
- canonical = json.loads(canonical_profile.read_text(encoding="utf-8"))
425
- del canonical["wrapup"]
426
- canonical_profile.write_text(json.dumps(canonical), encoding="utf-8")
427
- blocker = worktree / "dist-kit/bootstrap.tgz"
428
- blocker.parent.mkdir(parents=True)
429
- blocker.write_text("preserve\n", encoding="utf-8")
430
-
431
- with self.assertRaises(wrapup.Stop) as stopped:
432
- wrapup.landing_start_artifact_inventory(
433
- str(worktree), str(main)
434
- )
435
-
436
- self.assertIn(
437
- "landing-start generated paths are consumer-owned",
438
- stopped.exception.reason,
439
- )
440
- self.assertFalse(baseline_path.exists())
441
- self.assertFalse(
442
- baseline_path.with_name(core.LANDING_ATTEMPT_FILE).exists()
443
- )
444
-
445
- def test_candidate_policy_cannot_widen_post_merge_deletion_authority(self):
446
- wrapup = load_wrapup()
447
- with tempfile.TemporaryDirectory() as tmp:
448
- main, worktree = create_merged_worktree(Path(tmp))
449
- candidate_path = worktree / "docs/agents/workflow-capabilities.json"
450
- candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
451
- candidate["wrapup"]["landingGeneratedArtifactPatterns"].append("**")
452
- candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
453
- command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
454
- command(["git", "commit", "-m", "widen candidate policy"], worktree)
455
- core = wrapup.load_worktree_cleanup_core()
456
- attempt_path = core.artifact_baseline_path(worktree).with_name(
457
- core.LANDING_ATTEMPT_FILE
458
- )
459
-
460
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
461
- with self.assertRaises(wrapup.Stop) as stopped:
462
- wrapup.ensure_worktree_removable(str(worktree), str(main))
463
-
464
- self.assertIn(
465
- "worktree cleanup policy differs from merged canonical origin/main",
466
- stopped.exception.reason,
467
- )
468
- self.assertTrue(attempt_path.exists())
469
-
470
- def test_policy_drift_during_attempt_cannot_claim_preexisting_consumer_file(self):
471
- wrapup = load_wrapup()
472
- with tempfile.TemporaryDirectory() as tmp:
473
- main, worktree = create_merged_worktree(Path(tmp))
474
- victim = worktree / "consumer/victim.txt"
475
- victim.parent.mkdir(parents=True)
476
- victim.write_text("preserve\n", encoding="utf-8")
477
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
478
- candidate_path = worktree / "docs/agents/workflow-capabilities.json"
479
- candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
480
- candidate["wrapup"]["landingGeneratedArtifactPatterns"].append(
481
- "consumer/**"
482
- )
483
- candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
484
- command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
485
- command(["git", "commit", "-m", "widen active attempt policy"], worktree)
486
-
487
- with self.assertRaises(wrapup.Stop) as stopped:
488
- wrapup.freeze_landing_artifact_evidence(
489
- str(worktree), str(main), push_succeeded=True
490
- )
491
-
492
- self.assertIn(
493
- "landing cleanup policy changed after attempt start",
494
- stopped.exception.reason,
495
- )
496
- self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
497
-
498
- def test_canonical_cleanup_rejects_generator_evidence_outside_policy(self):
499
- wrapup = load_wrapup()
500
- with tempfile.TemporaryDirectory() as tmp:
501
- main, worktree = create_merged_worktree(Path(tmp))
502
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
503
- victim = worktree / "consumer/victim.txt"
504
- victim.parent.mkdir(parents=True)
505
- victim.write_text("preserve\n", encoding="utf-8")
506
- core = wrapup.load_worktree_cleanup_core()
507
- baseline = core.load_artifact_baseline(worktree)
508
- with core.verified_worktree_root(
509
- worktree, baseline.root_device, baseline.root_inode
510
- ) as descriptor:
511
- evidence = (
512
- core.contained_regular_identity(
513
- descriptor, "consumer/victim.txt"
514
- ),
515
- )
516
-
517
- with self.assertRaises(wrapup.Stop) as stopped:
518
- wrapup.ensure_worktree_removable(
519
- str(worktree),
520
- str(main),
521
- verified_scratch_evidence=evidence,
522
- )
523
-
524
- self.assertIn(
525
- "generator evidence is outside canonical landing policy",
526
- stopped.exception.detail,
527
- )
528
- self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
529
-
530
- def test_missing_local_main_profile_cannot_disable_canonical_guard(self):
531
- wrapup = load_wrapup()
532
- with tempfile.TemporaryDirectory() as tmp:
533
- main, worktree = create_merged_worktree(Path(tmp))
534
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
535
- wrapup.freeze_landing_artifact_evidence(
536
- str(worktree), str(main), push_succeeded=True
537
- )
538
- (main / "docs/agents/workflow-capabilities.json").unlink()
539
- victim = worktree / "consumer/ignored.txt"
540
- victim.parent.mkdir(parents=True)
541
- victim.write_text("preserve\n", encoding="utf-8")
542
-
543
- with self.assertRaises(wrapup.Stop) as stopped:
544
- wrapup.ensure_worktree_removable(str(worktree), str(main))
545
-
546
- self.assertIn(
547
- "dirty worktree: untracked non-scratch: consumer/ignored.txt",
548
- stopped.exception.detail,
549
- )
550
- self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
551
- self.assertTrue(worktree.is_dir())
552
-
553
- def test_explicit_empty_landing_policy_is_distinct_from_missing(self):
554
- wrapup = load_wrapup()
555
- core = wrapup.load_worktree_cleanup_core()
556
- with tempfile.TemporaryDirectory() as tmp:
557
- profile_path = Path(tmp) / "workflow-capabilities.json"
558
- document = {
559
- "worktreeLifecycle": {"enabled": True},
560
- "wrapup": {"landingGeneratedArtifactPatterns": []},
561
- }
562
- profile_path.write_text(json.dumps(document), encoding="utf-8")
563
- configured = core.load_profile(profile_path)
564
- del document["wrapup"]
565
- profile_path.write_text(json.dumps(document), encoding="utf-8")
566
- missing = core.load_profile(profile_path)
567
-
568
- self.assertTrue(
569
- configured.landing_generated_artifact_policy_configured
570
- )
571
- self.assertEqual(configured.landing_generated_artifact_patterns, ())
572
- self.assertFalse(missing.landing_generated_artifact_policy_configured)
573
-
574
- def test_abandon_archives_drifted_frozen_attempt_without_touching_files(self):
575
- wrapup = load_wrapup()
576
- with tempfile.TemporaryDirectory() as tmp:
577
- main, worktree = create_merged_worktree(Path(tmp))
578
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
579
- generated = worktree / "dist-kit/package.tgz"
580
- generated.parent.mkdir(parents=True)
581
- generated.write_text("one\n", encoding="utf-8")
582
- wrapup.freeze_landing_artifact_evidence(
583
- str(worktree), str(main), push_succeeded=True
584
- )
585
- generated.write_text("two\n", encoding="utf-8")
586
-
587
- args = SimpleNamespace(
588
- branch="fix/268-cleanup",
589
- body_file=None,
590
- title=None,
591
- anchor=None,
592
- skip_malformed_drift=False,
593
- abandon_unfinished_attempt=True,
594
- )
595
- previous = Path.cwd()
596
- try:
597
- os.chdir(main)
598
- result = wrapup.cmd_land(args)
599
- finally:
600
- os.chdir(previous)
601
- archive = Path(result["landing_attempt_abandoned"])
602
-
603
- self.assertTrue(archive.is_file())
604
- self.assertEqual(generated.read_text(encoding="utf-8"), "two\n")
605
- with self.assertRaises(wrapup.Stop):
606
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
607
-
608
- def test_cli_abandon_archives_attempt_even_when_baseline_is_missing(self):
609
- wrapup = load_wrapup()
610
- with tempfile.TemporaryDirectory() as tmp:
611
- main, worktree = create_merged_worktree(Path(tmp))
612
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
613
- generated = worktree / "dist-kit/ambiguous.tgz"
614
- generated.parent.mkdir(parents=True)
615
- generated.write_text("keep\n", encoding="utf-8")
616
- core = wrapup.load_worktree_cleanup_core()
617
- core.artifact_baseline_path(worktree).unlink()
618
- args = SimpleNamespace(
619
- branch="fix/268-cleanup",
620
- body_file=None,
621
- title=None,
622
- anchor=None,
623
- skip_malformed_drift=False,
624
- abandon_unfinished_attempt=True,
625
- )
626
- previous = Path.cwd()
627
- try:
628
- os.chdir(main)
629
- result = wrapup.cmd_land(args)
630
- finally:
631
- os.chdir(previous)
632
- self.assertTrue(Path(result["landing_attempt_abandoned"]).is_file())
633
- self.assertEqual(generated.read_text(encoding="utf-8"), "keep\n")
634
-
635
- def test_profile_globs_match_root_and_nested_without_star_crossing_slash(self):
636
- wrapup = load_wrapup()
637
- core = wrapup.load_worktree_cleanup_core()
638
- self.assertTrue(core.path_glob_matches("__pycache__/a.pyc", "**/__pycache__/**"))
639
- self.assertTrue(core.path_glob_matches("src/__pycache__/a.pyc", "**/__pycache__/**"))
640
- self.assertTrue(core.path_glob_matches("dist-kit/a", "dist-kit/**"))
641
- self.assertTrue(core.path_glob_matches("dist-kit/a/b", "dist-kit/**"))
642
- self.assertFalse(core.path_glob_matches("dist-kit/a/b", "dist-kit/*"))
643
- self.assertTrue(core.path_glob_matches("cache/7.tmp", "cache/[0-9].tmp"))
644
- self.assertFalse(core.path_glob_matches("cache/x.tmp", "cache/[0-9].tmp"))
645
-
646
- def test_active_profile_delegates_removal_safety_to_shared_assessment(self):
647
- wrapup = load_wrapup()
648
- calls = []
649
-
650
- class FakeCore:
651
- LifecycleError = RuntimeError
652
-
653
- @staticmethod
654
- def load_profile(path):
655
- calls.append(("profile", path))
656
- return SimpleNamespace(
657
- landing_generated_artifact_policy_configured=True,
658
- landing_generated_artifact_patterns=(),
659
- scratch_patterns=(),
660
- )
661
-
662
- @staticmethod
663
- def cleanup_assessment(profile, main, target, merge_target=None):
664
- calls.append(("assessment", main, target, merge_target))
665
- return SimpleNamespace(reasons=("dirty worktree",), assumptions="reviewed")
666
-
667
- with tempfile.TemporaryDirectory() as tmp:
668
- main = Path(tmp)
669
- profile = main / "docs/agents/workflow-capabilities.json"
670
- profile.parent.mkdir(parents=True)
671
- profile.write_text('{"worktreeLifecycle":{"enabled":true}}\n')
672
- with (
673
- patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore),
674
- patch.object(
675
- wrapup,
676
- "load_canonical_landing_profile",
677
- return_value=SimpleNamespace(),
678
- ),
679
- ):
680
- with self.assertRaises(wrapup.Stop) as stopped:
681
- wrapup.ensure_worktree_removable(str(main / "wt"), str(main))
682
-
683
- self.assertIn("shared cleanup guard", stopped.exception.reason)
684
- self.assertEqual(calls[-1][-1], "origin/main")
685
-
686
- def test_real_build_and_python_check_after_baseline_are_cleaned_by_land(self):
687
- wrapup = load_wrapup()
688
- with tempfile.TemporaryDirectory() as tmp:
689
- root = Path(tmp)
690
- main, worktree = create_real_kit_merged_worktree(root)
691
- appendable = worktree / ".claude/logs/session.log"
692
- appendable.parent.mkdir(parents=True, exist_ok=True)
693
- appendable.write_text("appendable\n", encoding="utf-8")
694
- (worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
695
- # The first landing attempt journals its start before the real
696
- # pre-push generators run. A resumed already-MERGED land must
697
- # reuse that attempt instead of treating its outputs as user files.
698
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
699
- command(["npm", "run", "kit:build"], worktree)
700
- command([
701
- os.sys.executable,
702
- "-m",
703
- "py_compile",
704
- "scripts/worktree-lifecycle/core.py",
705
- ], worktree)
706
- wrapup.freeze_landing_artifact_evidence(
707
- str(worktree),
708
- str(main),
709
- push_succeeded=True,
710
- )
711
- self.assertTrue((worktree / "dist-kit/package.json").is_file())
712
- self.assertTrue(
713
- any(
714
- (worktree / "scripts/worktree-lifecycle/__pycache__").glob(
715
- "core.*.pyc"
716
- )
717
- )
718
- )
719
-
720
- real_run = wrapup.run
721
-
722
- def landing_run(args, cwd=None, check=False):
723
- if args[:3] == ["gh", "pr", "view"]:
724
- fields = args[-1]
725
- payload = (
726
- {"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
727
- if "number,state,body" in fields
728
- else {"state": "MERGED"}
729
- )
730
- return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
731
- if args[:3] == ["gh", "issue", "view"]:
732
- return subprocess.CompletedProcess(
733
- args, 0, json.dumps({"state": "CLOSED"}), ""
734
- )
735
- if args[:3] == ["gh", "pr", "list"]:
736
- return subprocess.CompletedProcess(args, 0, "", "")
737
- if args and args[0] == os.sys.executable:
738
- return subprocess.CompletedProcess(args, 0, "", "")
739
- return real_run(args, cwd=cwd, check=check)
740
-
741
- args = SimpleNamespace(
742
- branch="fix/268-real-generator",
743
- body_file=None,
744
- title=None,
745
- anchor=None,
746
- skip_malformed_drift=False,
747
- )
748
- previous = Path.cwd()
749
- try:
750
- os.chdir(main)
751
- with (
752
- patch.object(wrapup, "run", side_effect=landing_run),
753
- patch.object(wrapup, "wait_for_merge_gate", return_value=True),
754
- patch.object(wrapup, "kill_worktree_processes", return_value=[]),
755
- ):
756
- first = wrapup.cmd_land(args)
757
- second = wrapup.cmd_land(args)
758
- finally:
759
- os.chdir(previous)
760
-
761
- generated = first["cleanup_guard"]["landing_generated_files"]
762
- self.assertIn("dist-kit/package.json", generated)
763
- self.assertTrue(
764
- any(
765
- path.startswith(
766
- "scripts/worktree-lifecycle/__pycache__/core."
767
- )
768
- and path.endswith(".pyc")
769
- for path in generated
770
- ),
771
- )
772
- self.assertEqual(first["worktree_removed"], str(worktree))
773
- self.assertFalse(worktree.exists())
774
- self.assertTrue(second["merged"])
775
-
776
- def test_pnpm_symlink_farm_reaches_guarded_land_idempotently(self):
777
- wrapup = load_wrapup()
778
- with tempfile.TemporaryDirectory() as tmp:
779
- main, worktree = create_merged_worktree(
780
- Path(tmp), scratch_patterns=["node_modules/**"]
781
- )
782
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
783
- wrapup.freeze_landing_artifact_evidence(
784
- str(worktree), str(main), push_succeeded=True
785
- )
786
- package = (
787
- worktree
788
- / "node_modules/.pnpm/example@1.0.0/node_modules/example"
789
- )
790
- package.mkdir(parents=True)
791
- (package / "index.js").write_text(
792
- "export default 1;\n", encoding="utf-8"
793
- )
794
- (worktree / "node_modules/example").symlink_to(
795
- ".pnpm/example@1.0.0/node_modules/example"
796
- )
797
-
798
- first = run_land(wrapup, main, land_args())
799
- second = run_land(wrapup, main, land_args())
800
-
801
- self.assertEqual(first["worktree_removed"], str(worktree))
802
- self.assertFalse(worktree.exists())
803
- self.assertTrue(second["merged"])
804
-
805
- def test_append_after_profile_log_assessment_is_preserved_and_stops(self):
806
- wrapup = load_wrapup()
807
- with tempfile.TemporaryDirectory() as tmp:
808
- main, worktree = create_merged_worktree(
809
- Path(tmp), scratch_patterns=[".claude/logs/**"]
810
- )
811
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
812
- log = worktree / ".claude/logs/session.log"
813
- log.parent.mkdir(parents=True)
814
- log.write_text("assessed\n", encoding="utf-8")
815
- assessment = wrapup.ensure_worktree_removable(
816
- str(worktree), str(main)
817
- )
818
- with log.open("a", encoding="utf-8") as handle:
819
- handle.write("late\n")
820
- with self.assertRaises(wrapup.Stop):
821
- wrapup.remove_verified_worktree_scratch(
822
- str(worktree), str(main), assessment
823
- )
824
- self.assertEqual(log.read_text(encoding="utf-8"), "assessed\nlate\n")
825
-
826
- def test_profile_generated_path_present_in_creation_baseline_is_not_scratch(self):
827
- wrapup = load_wrapup()
828
- setup_step = {
829
- "kind": "command",
830
- "command": [
831
- os.sys.executable,
832
- "-c",
833
- (
834
- "from pathlib import Path; "
835
- "p=Path('dist-kit/setup-owned.txt'); "
836
- "p.parent.mkdir(parents=True); p.write_text('initial')"
837
- ),
838
- ],
839
- }
840
- with tempfile.TemporaryDirectory() as tmp:
841
- main, worktree = create_merged_worktree(
842
- Path(tmp),
843
- setup_steps=[setup_step],
844
- )
845
-
846
- with self.assertRaises(wrapup.Stop) as stopped:
847
- wrapup.landing_verified_scratch_files(
848
- str(worktree),
849
- str(main),
850
- )
851
-
852
- self.assertIn("dist-kit/setup-owned.txt", stopped.exception.reason)
853
-
854
- def test_missing_creation_baseline_fails_safe(self):
855
- wrapup = load_wrapup()
856
- with tempfile.TemporaryDirectory() as tmp:
857
- main, worktree = create_merged_worktree(Path(tmp))
858
- core = wrapup.load_worktree_cleanup_core()
859
- core.artifact_baseline_path(worktree).unlink()
860
-
861
- with self.assertRaises(wrapup.Stop) as stopped:
862
- wrapup.landing_verified_scratch_files(
863
- str(worktree),
864
- str(main),
865
- )
866
-
867
- self.assertIn("artifact provenance baseline", stopped.exception.reason)
868
-
869
- def test_incoherent_creation_baseline_fails_safe(self):
870
- wrapup = load_wrapup()
871
- with tempfile.TemporaryDirectory() as tmp:
872
- main, worktree = create_merged_worktree(Path(tmp))
873
- core = wrapup.load_worktree_cleanup_core()
874
- baseline_path = core.artifact_baseline_path(worktree)
875
- baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
876
- baseline["branch"] = "fix/999-foreign"
877
- baseline_path.write_text(json.dumps(baseline), encoding="utf-8")
878
-
879
- with self.assertRaises(wrapup.Stop) as stopped:
880
- wrapup.landing_verified_scratch_files(
881
- str(worktree),
882
- str(main),
883
- )
884
-
885
- self.assertIn("artifact provenance baseline", stopped.exception.reason)
886
-
887
- def test_arbitrary_ignored_file_stops_exact_landing_cleanup(self):
888
- wrapup = load_wrapup()
889
- with tempfile.TemporaryDirectory() as tmp:
890
- main, worktree = create_merged_worktree(Path(tmp))
891
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
892
- generated = worktree / "dist-kit/package.tgz"
893
- generated.parent.mkdir(parents=True)
894
- generated.write_text("package", encoding="utf-8")
895
- consumer = worktree / "consumer/private.cache"
896
- consumer.parent.mkdir(parents=True)
897
- consumer.write_text("mine", encoding="utf-8")
898
-
899
- with self.assertRaises(wrapup.Stop) as stopped:
900
- wrapup.ensure_worktree_removable(
901
- str(worktree),
902
- str(main),
903
- verified_scratch_files=("dist-kit/package.tgz",),
904
- )
905
-
906
- self.assertIn("consumer/private.cache", stopped.exception.detail)
907
- self.assertTrue(generated.exists())
908
- self.assertTrue(consumer.exists())
909
-
910
- def test_symlink_in_generated_evidence_stops_before_foreign_target_is_touched(self):
911
- wrapup = load_wrapup()
912
- with tempfile.TemporaryDirectory() as tmp:
913
- root = Path(tmp)
914
- main, worktree = create_merged_worktree(root)
915
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
916
- foreign = root / "foreign.txt"
917
- foreign.write_text("keep", encoding="utf-8")
918
- generated = worktree / "dist-kit/package.tgz"
919
- generated.parent.mkdir(parents=True)
920
- generated.symlink_to(foreign)
921
- with self.assertRaises(wrapup.Stop) as stopped:
922
- wrapup.ensure_worktree_removable(
923
- str(worktree),
924
- str(main),
925
- verified_scratch_files=("dist-kit/package.tgz",),
926
- )
927
-
928
- self.assertIn("evidence is missing", stopped.exception.detail)
929
- self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
930
- self.assertTrue(generated.is_symlink())
931
-
932
- def test_late_generated_pattern_write_is_not_added_to_verified_evidence(self):
933
- wrapup = load_wrapup()
934
- with tempfile.TemporaryDirectory() as tmp:
935
- main, worktree = create_merged_worktree(Path(tmp))
936
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
937
- generated = worktree / "dist-kit/package.tgz"
938
- generated.parent.mkdir(parents=True)
939
- generated.write_text("package", encoding="utf-8")
940
- evidence = wrapup.landing_verified_scratch_evidence(
941
- str(worktree),
942
- str(main),
943
- landing_start_files=(),
944
- )
945
- assessment = wrapup.ensure_worktree_removable(
946
- str(worktree),
947
- str(main),
948
- verified_scratch_evidence=evidence,
949
- )
950
- late = worktree / "dist-kit/late.tgz"
951
- late.parent.mkdir(parents=True, exist_ok=True)
952
- late.write_text("late", encoding="utf-8")
953
-
954
- with self.assertRaises(wrapup.Stop) as stopped:
955
- wrapup.remove_verified_worktree_scratch(
956
- str(worktree),
957
- str(main),
958
- assessment,
959
- verified_scratch_evidence=evidence,
960
- )
961
-
962
- self.assertIn("dist-kit/late.tgz", stopped.exception.reason)
963
- self.assertTrue(generated.exists())
964
- self.assertTrue(late.exists())
965
-
966
- def test_late_profile_scratch_symlink_stops_before_assessed_links_are_removed(self):
967
- wrapup = load_wrapup()
968
- with tempfile.TemporaryDirectory() as tmp:
969
- main, worktree = create_merged_worktree(
970
- Path(tmp), scratch_patterns=["node_modules/**"]
971
- )
972
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
973
- packages = worktree / "node_modules/.pnpm"
974
- first_target = packages / "first@1.0.0/node_modules/first"
975
- first_target.mkdir(parents=True)
976
- first = worktree / "node_modules/first"
977
- first.symlink_to(".pnpm/first@1.0.0/node_modules/first")
978
- assessment = wrapup.ensure_worktree_removable(
979
- str(worktree), str(main)
980
- )
981
- late_target = packages / "late@1.0.0/node_modules/late"
982
- late_target.mkdir(parents=True)
983
- late = worktree / "node_modules/late"
984
- late.symlink_to(".pnpm/late@1.0.0/node_modules/late")
985
-
986
- with self.assertRaises(wrapup.Stop) as stopped:
987
- wrapup.remove_verified_worktree_scratch(
988
- str(worktree), str(main), assessment
989
- )
990
-
991
- self.assertIn(
992
- "inventory no longer matches preview", stopped.exception.reason
993
- )
994
- self.assertTrue(first.is_symlink())
995
- self.assertTrue(late.is_symlink())
996
-
997
- def test_changed_internal_symlink_target_identity_stops_cleanup(self):
998
- wrapup = load_wrapup()
999
- with tempfile.TemporaryDirectory() as tmp:
1000
- main, worktree = create_merged_worktree(
1001
- Path(tmp), scratch_patterns=["PLAN.md"]
1002
- )
1003
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1004
- link = worktree / "PLAN.md"
1005
- link.symlink_to("change.txt")
1006
- assessment = wrapup.ensure_worktree_removable(
1007
- str(worktree), str(main)
1008
- )
1009
- tracked_target = worktree / "change.txt"
1010
- original = tracked_target.read_text(encoding="utf-8")
1011
- replacement = worktree / "replacement.tmp"
1012
- replacement.write_text(original, encoding="utf-8")
1013
- os.replace(replacement, tracked_target)
1014
-
1015
- with self.assertRaises(wrapup.Stop) as stopped:
1016
- wrapup.remove_verified_worktree_scratch(
1017
- str(worktree), str(main), assessment
1018
- )
1019
-
1020
- self.assertIn(
1021
- "inventory no longer matches preview", stopped.exception.reason
1022
- )
1023
- self.assertTrue(link.is_symlink())
1024
- self.assertEqual(tracked_target.read_text(encoding="utf-8"), original)
1025
-
1026
- def test_same_path_generated_replacement_is_preserved_by_frozen_evidence(self):
1027
- wrapup = load_wrapup()
1028
- with tempfile.TemporaryDirectory() as tmp:
1029
- main, worktree = create_merged_worktree(Path(tmp))
1030
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1031
- generated = worktree / "dist-kit/package.tgz"
1032
- generated.parent.mkdir(parents=True)
1033
- generated.write_text("generator-owned", encoding="utf-8")
1034
- evidence = wrapup.landing_verified_scratch_evidence(
1035
- str(worktree),
1036
- str(main),
1037
- landing_start_files=(),
1038
- )
1039
- assessment = wrapup.ensure_worktree_removable(
1040
- str(worktree),
1041
- str(main),
1042
- verified_scratch_evidence=evidence,
1043
- )
1044
- generated.unlink()
1045
- generated.write_text("user replacement", encoding="utf-8")
1046
-
1047
- with self.assertRaises(wrapup.Stop) as stopped:
1048
- wrapup.remove_verified_worktree_scratch(
1049
- str(worktree),
1050
- str(main),
1051
- assessment,
1052
- verified_scratch_evidence=evidence,
1053
- )
1054
-
1055
- self.assertIn("identity changed", stopped.exception.reason)
1056
- self.assertEqual(generated.read_text(encoding="utf-8"), "user replacement")
1057
-
1058
- def test_landing_start_generated_file_is_rejected_before_attempt_is_written(self):
1059
- wrapup = load_wrapup()
1060
- with tempfile.TemporaryDirectory() as tmp:
1061
- main, worktree = create_merged_worktree(Path(tmp))
1062
- user_file = worktree / "dist-kit/user-note.txt"
1063
- user_file.parent.mkdir(parents=True)
1064
- user_file.write_text("keep", encoding="utf-8")
1065
- with self.assertRaises(wrapup.Stop) as stopped:
1066
- wrapup.landing_start_artifact_inventory(
1067
- str(worktree), str(main)
1068
- )
1069
-
1070
- self.assertIn("landing-start generated paths", stopped.exception.reason)
1071
- self.assertTrue(user_file.exists())
1072
- core = wrapup.load_worktree_cleanup_core()
1073
- self.assertFalse(
1074
- core.artifact_baseline_path(worktree)
1075
- .with_name(core.LANDING_ATTEMPT_FILE)
1076
- .exists()
1077
- )
1078
-
1079
- def test_frozen_landing_output_does_not_claim_a_later_private_path(self):
1080
- wrapup = load_wrapup()
1081
- with tempfile.TemporaryDirectory() as tmp:
1082
- main, worktree = create_merged_worktree(Path(tmp))
1083
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1084
- generated = worktree / "dist-kit/package.tgz"
1085
- generated.parent.mkdir(parents=True)
1086
- generated.write_text("generator", encoding="utf-8")
1087
- wrapup.freeze_landing_artifact_evidence(
1088
- str(worktree), str(main), push_succeeded=True
1089
- )
1090
- private = worktree / "dist-kit/private.log"
1091
- private.parent.mkdir(parents=True, exist_ok=True)
1092
- private.write_text("keep", encoding="utf-8")
1093
-
1094
- with self.assertRaises(wrapup.Stop) as stopped:
1095
- wrapup.freeze_landing_artifact_evidence(
1096
- str(worktree), str(main), push_succeeded=True
1097
- )
1098
-
1099
- self.assertIn("evidence changed", stopped.exception.reason)
1100
- self.assertTrue(generated.exists())
1101
- self.assertTrue(private.exists())
1102
-
1103
- def test_failed_push_retry_validates_replacement_before_invoking_push(self):
1104
- wrapup = load_wrapup()
1105
- with tempfile.TemporaryDirectory() as tmp:
1106
- main, worktree = create_merged_worktree(Path(tmp))
1107
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1108
- generated = worktree / "dist-kit/package.tgz"
1109
- generated.parent.mkdir(parents=True)
1110
- generated.write_text("generator", encoding="utf-8")
1111
- wrapup.freeze_landing_artifact_evidence(
1112
- str(worktree), str(main), push_succeeded=False
1113
- )
1114
- generated.unlink()
1115
- generated.write_text("user replacement", encoding="utf-8")
1116
- pushes = []
1117
- real_git = wrapup.git
1118
-
1119
- def observed_git(args, **kwargs):
1120
- if args and args[0] == "push":
1121
- pushes.append(args)
1122
- return real_git(args, **kwargs)
1123
-
1124
- args = SimpleNamespace(
1125
- branch="fix/268-cleanup",
1126
- body_file=None,
1127
- title=None,
1128
- anchor=None,
1129
- skip_malformed_drift=False,
1130
- abandon_unfinished_attempt=False,
1131
- )
1132
- previous = Path.cwd()
1133
- try:
1134
- os.chdir(main)
1135
- with (
1136
- patch.object(wrapup, "git", side_effect=observed_git),
1137
- self.assertRaises(wrapup.Stop) as stopped,
1138
- ):
1139
- wrapup.cmd_land(args)
1140
- finally:
1141
- os.chdir(previous)
1142
-
1143
- self.assertIn("evidence changed", stopped.exception.reason)
1144
- self.assertEqual(pushes, [])
1145
- self.assertEqual(
1146
- generated.read_text(encoding="utf-8"), "user replacement"
1147
- )
1148
-
1149
- def test_first_attempt_protects_preexisting_generated_path_before_push(self):
1150
- wrapup = load_wrapup()
1151
- with tempfile.TemporaryDirectory() as tmp:
1152
- main, worktree = create_merged_worktree(Path(tmp))
1153
- user_file = worktree / "dist-kit/package.json"
1154
- user_file.parent.mkdir(parents=True)
1155
- user_file.write_text("user bytes", encoding="utf-8")
1156
- pushes = []
1157
- real_git = wrapup.git
1158
-
1159
- def observed_git(args, **kwargs):
1160
- if args and args[0] == "push":
1161
- pushes.append(args)
1162
- return real_git(args, **kwargs)
1163
-
1164
- args = SimpleNamespace(
1165
- branch="fix/268-cleanup",
1166
- body_file=None,
1167
- title=None,
1168
- anchor=None,
1169
- skip_malformed_drift=False,
1170
- abandon_unfinished_attempt=False,
1171
- )
1172
- previous = Path.cwd()
1173
- try:
1174
- os.chdir(main)
1175
- with (
1176
- patch.object(wrapup, "git", side_effect=observed_git),
1177
- self.assertRaises(wrapup.Stop) as stopped,
1178
- ):
1179
- wrapup.cmd_land(args)
1180
- finally:
1181
- os.chdir(previous)
1182
-
1183
- self.assertIn("consumer-owned", stopped.exception.reason)
1184
- self.assertEqual(pushes, [])
1185
- self.assertEqual(user_file.read_text(encoding="utf-8"), "user bytes")
1186
-
1187
- def test_unfinished_attempt_can_be_archived_without_claiming_ambiguous_files(self):
1188
- wrapup = load_wrapup()
1189
- with tempfile.TemporaryDirectory() as tmp:
1190
- main, worktree = create_merged_worktree(Path(tmp))
1191
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1192
- core = wrapup.load_worktree_cleanup_core()
1193
- baseline_path = core.artifact_baseline_path(worktree)
1194
- attempt_path = baseline_path.with_name(core.LANDING_ATTEMPT_FILE)
1195
- attempt = json.loads(attempt_path.read_text(encoding="utf-8"))
1196
- payload = {
1197
- key: value
1198
- for key, value in attempt.items()
1199
- if key not in {"policyDigest", "sha256"}
1200
- }
1201
- payload["contractVersion"] = 1
1202
- attempt_path.write_text(
1203
- json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
1204
- encoding="utf-8",
1205
- )
1206
- baseline_path.unlink()
1207
- ambiguous = worktree / "dist-kit/ambiguous.tgz"
1208
- ambiguous.parent.mkdir(parents=True)
1209
- ambiguous.write_text("unknown owner", encoding="utf-8")
1210
- (main / "docs/agents/workflow-capabilities.json").unlink()
1211
-
1212
- archive = Path(wrapup.abandon_unfinished_landing_attempt(
1213
- str(worktree), str(main)
1214
- ))
1215
-
1216
- self.assertTrue(archive.is_file())
1217
- self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
1218
- with self.assertRaises(wrapup.Stop) as stopped:
1219
- wrapup.landing_start_artifact_inventory(
1220
- str(worktree), str(main)
1221
- )
1222
- self.assertIn("consumer-owned", stopped.exception.reason)
1223
-
1224
-
1225
- class LandingAttemptJournalContract(unittest.TestCase):
1226
- """#274 — one nofollow-safe journal classification and no dead v1 surface."""
1227
-
1228
- def test_symlinked_attempt_journal_is_refused_without_following_it(self):
1229
- wrapup = load_wrapup()
1230
- with tempfile.TemporaryDirectory() as tmp:
1231
- main, worktree = create_merged_worktree(Path(tmp))
1232
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1233
- core = wrapup.load_worktree_cleanup_core()
1234
- attempt_path = core.landing_attempt_path(worktree)
1235
- moved = attempt_path.with_name("relocated-attempt.json")
1236
- os.replace(attempt_path, moved)
1237
- attempt_path.symlink_to(moved)
1238
- frozen_bytes = moved.read_text(encoding="utf-8")
1239
-
1240
- with self.assertRaises(wrapup.Stop) as stopped:
1241
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1242
-
1243
- self.assertIn("not a regular file", stopped.exception.reason)
1244
- self.assertTrue(attempt_path.is_symlink())
1245
- self.assertEqual(moved.read_text(encoding="utf-8"), frozen_bytes)
1246
-
1247
- def test_dangling_attempt_symlink_is_never_silently_replaced(self):
1248
- wrapup = load_wrapup()
1249
- with tempfile.TemporaryDirectory() as tmp:
1250
- main, worktree = create_merged_worktree(Path(tmp))
1251
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1252
- core = wrapup.load_worktree_cleanup_core()
1253
- attempt_path = core.landing_attempt_path(worktree)
1254
- absent = attempt_path.with_name("absent-attempt.json")
1255
- attempt_path.unlink()
1256
- attempt_path.symlink_to(absent)
1257
-
1258
- with self.assertRaises(wrapup.Stop) as stopped:
1259
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1260
-
1261
- self.assertIn("not a regular file", stopped.exception.reason)
1262
- self.assertTrue(attempt_path.is_symlink())
1263
- self.assertFalse(os.path.lexists(absent))
1264
-
1265
- def test_attempt_presence_uses_one_nofollow_classification(self):
1266
- wrapup = load_wrapup()
1267
- with tempfile.TemporaryDirectory() as tmp:
1268
- main, worktree = create_merged_worktree(Path(tmp))
1269
- core = wrapup.load_worktree_cleanup_core()
1270
- attempt_path = core.landing_attempt_path(worktree)
1271
-
1272
- self.assertFalse(core.landing_attempt_exists(attempt_path))
1273
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1274
- self.assertTrue(core.landing_attempt_exists(attempt_path))
1275
- attempt_path.unlink()
1276
- attempt_path.symlink_to(attempt_path.with_name("absent-attempt.json"))
1277
- self.assertTrue(core.landing_attempt_exists(attempt_path))
1278
-
1279
- def test_archived_v2_receipt_name_is_not_stamped_v1(self):
1280
- wrapup = load_wrapup()
1281
- with tempfile.TemporaryDirectory() as tmp:
1282
- main, worktree = create_merged_worktree(Path(tmp))
1283
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1284
-
1285
- archive = Path(
1286
- wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1287
- )
1288
-
1289
- self.assertTrue(archive.is_file())
1290
- self.assertNotIn("-v1", archive.name)
1291
- self.assertIn(".v2.abandoned-", archive.name)
1292
- self.assertTrue(
1293
- archive.name.startswith(core_archive_stem(wrapup)),
1294
- archive.name,
1295
- )
1296
-
1297
- def test_archived_v1_receipt_name_reports_its_own_contract_version(self):
1298
- wrapup = load_wrapup()
1299
- with tempfile.TemporaryDirectory() as tmp:
1300
- main, worktree = create_merged_worktree(Path(tmp))
1301
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1302
- core = wrapup.load_worktree_cleanup_core()
1303
- downgrade_landing_attempt_to_v1(core, worktree)
1304
-
1305
- archive = Path(
1306
- wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1307
- )
1308
-
1309
- self.assertTrue(archive.is_file())
1310
- self.assertIn(".v1.abandoned-", archive.name)
1311
-
1312
- def test_cleanup_authorization_always_returns_an_assessment_or_stops(self):
1313
- """Coverage for the removed `cleanup is not None` dead branches."""
1314
- wrapup = load_wrapup()
1315
- with tempfile.TemporaryDirectory() as tmp:
1316
- main, worktree = create_merged_worktree(Path(tmp))
1317
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1318
-
1319
- assessment = wrapup.ensure_worktree_removable(str(worktree), str(main))
1320
- self.assertIsNotNone(assessment)
1321
- self.assertEqual(assessment.reasons, ())
1322
-
1323
- blocker = worktree / "consumer/blocker.txt"
1324
- blocker.parent.mkdir(parents=True)
1325
- blocker.write_text("keep", encoding="utf-8")
1326
- with self.assertRaises(wrapup.Stop):
1327
- wrapup.ensure_worktree_removable(str(worktree), str(main))
1328
- self.assertEqual(blocker.read_text(encoding="utf-8"), "keep")
1329
-
1330
- def test_land_reports_generated_files_without_a_dead_guard_flag(self):
1331
- wrapup = load_wrapup()
1332
- with tempfile.TemporaryDirectory() as tmp:
1333
- main, worktree = create_merged_worktree(Path(tmp))
1334
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1335
- generated = worktree / "dist-kit/package.tgz"
1336
- generated.parent.mkdir(parents=True)
1337
- generated.write_text("generated\n", encoding="utf-8")
1338
- wrapup.freeze_landing_artifact_evidence(
1339
- str(worktree), str(main), push_succeeded=True
1340
- )
1341
-
1342
- report = run_land(wrapup, main, land_args())
1343
-
1344
- self.assertEqual(
1345
- report["cleanup_guard"],
1346
- {
1347
- "assumptions_read": False,
1348
- "landing_generated_files": ["dist-kit/package.tgz"],
1349
- },
1350
- )
1351
- self.assertEqual(report["worktree_removed"], str(worktree))
1352
- self.assertFalse(worktree.exists())
1353
-
1354
- def test_tampered_v2_journal_claiming_generated_files_still_stops_land(self):
1355
- wrapup = load_wrapup()
1356
- with tempfile.TemporaryDirectory() as tmp:
1357
- main, worktree = create_merged_worktree(Path(tmp))
1358
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1359
- core = wrapup.load_worktree_cleanup_core()
1360
- generated = worktree / "dist-kit/package.tgz"
1361
- generated.parent.mkdir(parents=True)
1362
- generated.write_text("generated\n", encoding="utf-8")
1363
- wrapup.freeze_landing_artifact_evidence(
1364
- str(worktree), str(main), push_succeeded=True
1365
- )
1366
- victim = worktree / "dist-kit/claimed.tgz"
1367
- victim.write_text("consumer bytes", encoding="utf-8")
1368
- rewrite_landing_attempt(
1369
- core,
1370
- worktree,
1371
- lambda payload: payload.__setitem__(
1372
- "generatedFiles", ["dist-kit/claimed.tgz"]
1373
- ),
1374
- )
1375
-
1376
- with self.assertRaises(wrapup.Stop) as stopped:
1377
- run_land(wrapup, main, land_args())
1378
-
1379
- self.assertIn("consumer-owned", stopped.exception.reason)
1380
- self.assertIn("dist-kit/claimed.tgz", stopped.exception.reason)
1381
- self.assertEqual(victim.read_text(encoding="utf-8"), "consumer bytes")
1382
- self.assertTrue(worktree.is_dir())
1383
-
1384
-
1385
- class LegacyLandingAttemptContract(unittest.TestCase):
1386
- """#275 — a coherent v1 journal is legacy, not corruption, and has a route out."""
1387
-
1388
- def assert_legacy_stop(self, stop):
1389
- self.assertIn("superseded v1 journal contract", stop.reason)
1390
- self.assertIn("--abandon-unfinished-attempt", stop.reason)
1391
- self.assertNotIn("incoherent", stop.reason)
1392
-
1393
- def test_coherent_v1_started_journal_is_classified_as_legacy(self):
1394
- wrapup = load_wrapup()
1395
- with tempfile.TemporaryDirectory() as tmp:
1396
- main, worktree = create_merged_worktree(Path(tmp))
1397
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1398
- core = wrapup.load_worktree_cleanup_core()
1399
- downgrade_landing_attempt_to_v1(core, worktree)
1400
-
1401
- with self.assertRaises(wrapup.Stop) as stopped:
1402
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1403
-
1404
- self.assert_legacy_stop(stopped.exception)
1405
-
1406
- def test_coherent_v1_frozen_journal_is_classified_as_legacy(self):
1407
- wrapup = load_wrapup()
1408
- with tempfile.TemporaryDirectory() as tmp:
1409
- main, worktree = create_merged_worktree(Path(tmp))
1410
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1411
- generated = worktree / "dist-kit/package.tgz"
1412
- generated.parent.mkdir(parents=True)
1413
- generated.write_text("generated\n", encoding="utf-8")
1414
- wrapup.freeze_landing_artifact_evidence(
1415
- str(worktree), str(main), push_succeeded=True
1416
- )
1417
- core = wrapup.load_worktree_cleanup_core()
1418
- downgrade_landing_attempt_to_v1(core, worktree)
1419
-
1420
- with self.assertRaises(wrapup.Stop) as stopped:
1421
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1422
-
1423
- self.assert_legacy_stop(stopped.exception)
1424
- self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1425
-
1426
- def test_incoherent_v1_journal_stays_classified_as_corruption(self):
1427
- wrapup = load_wrapup()
1428
- with tempfile.TemporaryDirectory() as tmp:
1429
- main, worktree = create_merged_worktree(Path(tmp))
1430
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1431
- core = wrapup.load_worktree_cleanup_core()
1432
-
1433
- def mutate(payload):
1434
- payload.pop("policyDigest", None)
1435
- payload["contractVersion"] = 1
1436
- payload["branch"] = "fix/999-foreign"
1437
-
1438
- rewrite_landing_attempt(core, worktree, mutate)
1439
-
1440
- with self.assertRaises(wrapup.Stop) as stopped:
1441
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1442
-
1443
- self.assertIn("incoherent", stopped.exception.reason)
1444
-
1445
- def test_land_names_the_safe_abandon_command_for_a_legacy_attempt(self):
1446
- wrapup = load_wrapup()
1447
- with tempfile.TemporaryDirectory() as tmp:
1448
- main, worktree = create_merged_worktree(Path(tmp))
1449
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1450
- core = wrapup.load_worktree_cleanup_core()
1451
- downgrade_landing_attempt_to_v1(core, worktree)
1452
- consumer = worktree / "consumer/keep.txt"
1453
- consumer.parent.mkdir(parents=True)
1454
- consumer.write_text("mine", encoding="utf-8")
1455
-
1456
- with self.assertRaises(wrapup.Stop) as stopped:
1457
- run_land(wrapup, main, land_args())
1458
-
1459
- self.assert_legacy_stop(stopped.exception)
1460
- self.assertTrue(worktree.is_dir())
1461
- self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1462
-
1463
- def test_v2_archival_stays_valid_without_baseline_or_local_main_profile(self):
1464
- wrapup = load_wrapup()
1465
- with tempfile.TemporaryDirectory() as tmp:
1466
- main, worktree = create_merged_worktree(Path(tmp))
1467
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1468
- core = wrapup.load_worktree_cleanup_core()
1469
- core.artifact_baseline_path(worktree).unlink()
1470
- (main / "docs/agents/workflow-capabilities.json").unlink()
1471
- ambiguous = worktree / "dist-kit/ambiguous.tgz"
1472
- ambiguous.parent.mkdir(parents=True)
1473
- ambiguous.write_text("unknown owner", encoding="utf-8")
1474
-
1475
- archive = Path(
1476
- wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1477
- )
1478
-
1479
- self.assertTrue(archive.is_file())
1480
- self.assertIn(".v2.abandoned-", archive.name)
1481
- self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
1482
-
1483
- def test_legacy_abandon_claims_no_generated_or_consumer_file(self):
1484
- wrapup = load_wrapup()
1485
- with tempfile.TemporaryDirectory() as tmp:
1486
- main, worktree = create_merged_worktree(Path(tmp))
1487
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1488
- generated = worktree / "dist-kit/package.tgz"
1489
- generated.parent.mkdir(parents=True)
1490
- generated.write_text("generated\n", encoding="utf-8")
1491
- wrapup.freeze_landing_artifact_evidence(
1492
- str(worktree), str(main), push_succeeded=True
1493
- )
1494
- consumer = worktree / "consumer/keep.txt"
1495
- consumer.parent.mkdir(parents=True)
1496
- consumer.write_text("mine", encoding="utf-8")
1497
- core = wrapup.load_worktree_cleanup_core()
1498
- downgrade_landing_attempt_to_v1(core, worktree)
1499
-
1500
- archive = Path(
1501
- wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
1502
- )
1503
-
1504
- self.assertTrue(archive.is_file())
1505
- self.assertIn(".v1.abandoned-", archive.name)
1506
- self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1507
- self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1508
- self.assertTrue(worktree.is_dir())
1509
-
1510
-
1511
- class CanonicalCleanupDriftRecovery(unittest.TestCase):
1512
- """#272 — canonical policy drift stays fail-closed but has a supported route out."""
1513
-
1514
- def drifted_merged_worktree(self, root: Path, wrapup, mutate) -> tuple[Path, Path]:
1515
- """Freeze a landing attempt, then drift canonical policy after the merge."""
1516
- main, worktree = create_merged_worktree(
1517
- root, scratch_patterns=[".claude/logs/**"]
1518
- )
1519
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1520
- generated = worktree / "dist-kit/package.tgz"
1521
- generated.parent.mkdir(parents=True)
1522
- generated.write_text("generated\n", encoding="utf-8")
1523
- wrapup.freeze_landing_artifact_evidence(
1524
- str(worktree), str(main), push_succeeded=True
1525
- )
1526
- profile_path = main / "docs/agents/workflow-capabilities.json"
1527
- document = json.loads(profile_path.read_text(encoding="utf-8"))
1528
- mutate(document)
1529
- profile_path.write_text(json.dumps(document), encoding="utf-8")
1530
- command(["git", "add", "docs/agents/workflow-capabilities.json"], main)
1531
- command(["git", "commit", "-m", "drift canonical cleanup policy"], main)
1532
- command(["git", "push", "origin", "main"], main)
1533
- return main, worktree
1534
-
1535
- @staticmethod
1536
- def widen_scratch(document):
1537
- document["worktreeLifecycle"]["scratchPatterns"] = [
1538
- ".claude/logs/**", "**/.cache/**",
1539
- ]
1540
-
1541
- @staticmethod
1542
- def drop_generator_pattern(document):
1543
- document["wrapup"]["landingGeneratedArtifactPatterns"] = [
1544
- "**/__pycache__/**",
1545
- ]
1546
-
1547
- def test_first_cleanup_stops_before_every_mutation_and_names_recovery(self):
1548
- wrapup = load_wrapup()
1549
- with tempfile.TemporaryDirectory() as tmp:
1550
- main, worktree = self.drifted_merged_worktree(
1551
- Path(tmp), wrapup, self.widen_scratch
1552
- )
1553
- generated = worktree / "dist-kit/package.tgz"
1554
-
1555
- with self.assertRaises(wrapup.Stop) as stopped:
1556
- run_land(wrapup, main, land_args())
1557
-
1558
- self.assertIn(
1559
- "worktree cleanup policy differs from merged canonical origin/main",
1560
- stopped.exception.reason,
1561
- )
1562
- self.assertIn("--recover-canonical-cleanup", stopped.exception.reason)
1563
- self.assertTrue(worktree.is_dir())
1564
- self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1565
- self.assertIn(
1566
- "fix/268-cleanup",
1567
- command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
1568
- )
1569
-
1570
- def test_recovery_retires_the_merged_worktree_and_branch_idempotently(self):
1571
- wrapup = load_wrapup()
1572
- with tempfile.TemporaryDirectory() as tmp:
1573
- main, worktree = self.drifted_merged_worktree(
1574
- Path(tmp), wrapup, self.widen_scratch
1575
- )
1576
- log = worktree / ".claude/logs/session.log"
1577
- log.parent.mkdir(parents=True)
1578
- log.write_text("session\n", encoding="utf-8")
1579
-
1580
- first = run_land(
1581
- wrapup, main, land_args(recover_canonical_cleanup=True)
1582
- )
1583
- second = run_land(
1584
- wrapup, main, land_args(recover_canonical_cleanup=True)
1585
- )
1586
-
1587
- self.assertEqual(first["worktree_removed"], str(worktree))
1588
- self.assertEqual(
1589
- first["cleanup_guard"]["landing_generated_files"],
1590
- ["dist-kit/package.tgz"],
1591
- )
1592
- self.assertFalse(worktree.exists())
1593
- self.assertEqual(first["branch_retired"], True)
1594
- self.assertEqual(second["worktree_removed"], None)
1595
- self.assertEqual(second["branch_retired"], "already absent")
1596
- self.assertEqual(
1597
- command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
1598
- "",
1599
- )
1600
-
1601
- def test_recovery_refuses_evidence_outside_canonical_policy(self):
1602
- wrapup = load_wrapup()
1603
- with tempfile.TemporaryDirectory() as tmp:
1604
- main, worktree = self.drifted_merged_worktree(
1605
- Path(tmp), wrapup, self.drop_generator_pattern
1606
- )
1607
- generated = worktree / "dist-kit/package.tgz"
1608
-
1609
- with self.assertRaises(wrapup.Stop) as stopped:
1610
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1611
-
1612
- self.assertIn(
1613
- "outside canonical cleanup policy", stopped.exception.reason
1614
- )
1615
- self.assertIn("dist-kit/package.tgz", stopped.exception.reason)
1616
- self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
1617
- self.assertTrue(worktree.is_dir())
1618
-
1619
- def test_recovery_refuses_a_changed_frozen_identity(self):
1620
- wrapup = load_wrapup()
1621
- with tempfile.TemporaryDirectory() as tmp:
1622
- main, worktree = self.drifted_merged_worktree(
1623
- Path(tmp), wrapup, self.widen_scratch
1624
- )
1625
- generated = worktree / "dist-kit/package.tgz"
1626
- generated.write_text("replaced by a user\n", encoding="utf-8")
1627
-
1628
- with self.assertRaises(wrapup.Stop) as stopped:
1629
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1630
-
1631
- self.assertIn("evidence changed", stopped.exception.reason)
1632
- self.assertEqual(
1633
- generated.read_text(encoding="utf-8"), "replaced by a user\n"
1634
- )
1635
- self.assertTrue(worktree.is_dir())
1636
-
1637
- def test_recovery_preserves_pre_existing_and_foreign_files(self):
1638
- wrapup = load_wrapup()
1639
- with tempfile.TemporaryDirectory() as tmp:
1640
- root = Path(tmp)
1641
- main, worktree = self.drifted_merged_worktree(
1642
- root, wrapup, self.widen_scratch
1643
- )
1644
- consumer = worktree / "consumer/private.cache"
1645
- consumer.parent.mkdir(parents=True)
1646
- consumer.write_text("mine", encoding="utf-8")
1647
- foreign = root / "foreign.txt"
1648
- foreign.write_text("keep", encoding="utf-8")
1649
-
1650
- with self.assertRaises(wrapup.Stop) as stopped:
1651
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1652
-
1653
- self.assertIn("consumer/private.cache", stopped.exception.detail)
1654
- self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
1655
- self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
1656
- self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1657
- self.assertTrue(worktree.is_dir())
1658
-
1659
- def test_recovery_refuses_an_unmerged_branch(self):
1660
- wrapup = load_wrapup()
1661
- with tempfile.TemporaryDirectory() as tmp:
1662
- main, worktree = create_merged_worktree(Path(tmp))
1663
- wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1664
- wrapup.freeze_landing_artifact_evidence(
1665
- str(worktree), str(main), push_succeeded=True
1666
- )
1667
- (worktree / "later.txt").write_text("unmerged\n", encoding="utf-8")
1668
- command(["git", "add", "later.txt"], worktree)
1669
- command(["git", "commit", "-m", "unmerged work"], worktree)
1670
-
1671
- with self.assertRaises(wrapup.Stop) as stopped:
1672
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1673
-
1674
- self.assertIn("not merged into canonical origin/main",
1675
- stopped.exception.reason)
1676
- self.assertTrue(worktree.is_dir())
1677
-
1678
- def test_recovery_refuses_an_unfrozen_attempt(self):
1679
- wrapup = load_wrapup()
1680
- with tempfile.TemporaryDirectory() as tmp:
1681
- main, worktree = self.drifted_merged_worktree(
1682
- Path(tmp), wrapup, self.widen_scratch
1683
- )
1684
- core = wrapup.load_worktree_cleanup_core()
1685
- rewrite_landing_attempt(
1686
- core,
1687
- worktree,
1688
- lambda payload: payload.update(
1689
- {"state": "started", "authorizedEvidence": [],
1690
- "pushSucceeded": False},
1691
- ),
1692
- )
1693
-
1694
- with self.assertRaises(wrapup.Stop) as stopped:
1695
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1696
-
1697
- self.assertIn("frozen landing attempt", stopped.exception.reason)
1698
- self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1699
- self.assertTrue(worktree.is_dir())
1700
-
1701
- def test_recovery_never_adopts_a_legacy_journal(self):
1702
- wrapup = load_wrapup()
1703
- with tempfile.TemporaryDirectory() as tmp:
1704
- main, worktree = self.drifted_merged_worktree(
1705
- Path(tmp), wrapup, self.widen_scratch
1706
- )
1707
- core = wrapup.load_worktree_cleanup_core()
1708
- downgrade_landing_attempt_to_v1(core, worktree)
1709
-
1710
- with self.assertRaises(wrapup.Stop) as stopped:
1711
- run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
1712
-
1713
- self.assertIn("incoherent", stopped.exception.reason)
1714
- self.assertTrue((worktree / "dist-kit/package.tgz").exists())
1715
- self.assertTrue(worktree.is_dir())
1716
-
1717
- def test_abandon_and_recovery_flags_are_mutually_exclusive(self):
1718
- result = subprocess.run(
1719
- [
1720
- os.sys.executable, str(WRAPUP), "land", "--branch", "fix/1-x",
1721
- "--abandon-unfinished-attempt", "--recover-canonical-cleanup",
1722
- ],
1723
- cwd=REPO,
1724
- capture_output=True,
1725
- text=True,
1726
- )
1727
-
1728
- self.assertEqual(result.returncode, 2)
1729
- self.assertIn("not allowed with argument", result.stderr)
1730
-
1731
-
1732
- def core_archive_stem(wrapup) -> str:
1733
- return wrapup.load_worktree_cleanup_core().LANDING_ATTEMPT_ARCHIVE_STEM
1734
-
1735
-
1736
- if __name__ == "__main__":
1737
- unittest.main()