@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,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()