@ikon85/agent-workflow-kit 0.36.5 → 0.38.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 (46) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +20 -12
  2. package/.agents/skills/setup-workflow/SKILL.md +24 -3
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
  5. package/.agents/skills/wrapup/SKILL.md +42 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
  8. package/.claude/skills/setup-workflow/SKILL.md +24 -3
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +33 -10
  13. package/README.md +88 -1
  14. package/agent-workflow-kit.package.json +36 -28
  15. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  16. package/docs/agents/workflow-capabilities.json +11 -0
  17. package/package.json +1 -1
  18. package/scripts/anchor_table.py +14 -8
  19. package/scripts/project-skill-extension.mjs +21 -2
  20. package/scripts/readiness.mjs +32 -4
  21. package/scripts/release-state.mjs +19 -9
  22. package/scripts/release-state.test.mjs +71 -8
  23. package/scripts/test_anchor_table.py +69 -0
  24. package/scripts/test_board_sync_create_idempotency.py +15 -2
  25. package/scripts/test_board_sync_wave_title.py +14 -2
  26. package/scripts/test_census_backstop.py +33 -0
  27. package/scripts/test_orchestrate_wave_contract.py +44 -0
  28. package/scripts/test_retro_wrapup_contract.py +19 -2
  29. package/scripts/test_worktree_wrapup_contract.py +1004 -3
  30. package/scripts/test_wrapup_land.py +428 -0
  31. package/scripts/workflow-advisories/core.py +44 -2
  32. package/scripts/worktree-lifecycle/README.md +126 -4
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +143 -5
  35. package/scripts/worktree-lifecycle/core.py +1383 -20
  36. package/scripts/worktree-lifecycle/profile.py +40 -3
  37. package/scripts/worktree-lifecycle/session.py +1857 -0
  38. package/scripts/worktree-lifecycle/setup.py +15 -0
  39. package/scripts/wrapup-land.py +650 -27
  40. package/src/cli.mjs +60 -27
  41. package/src/lib/bundle.mjs +1 -0
  42. package/src/lib/manifest.mjs +173 -3
  43. package/src/lib/projectSkillExtension.mjs +78 -1
  44. package/src/lib/updateCandidate.mjs +3 -1
  45. package/src/lib/updateDecisions.mjs +2 -2
  46. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
@@ -2,6 +2,9 @@
2
2
  """Wrapup must reuse the shipped Worktree Lifecycle cleanup assessment."""
3
3
 
4
4
  import importlib.util
5
+ import json
6
+ import os
7
+ import subprocess
5
8
  import tempfile
6
9
  import unittest
7
10
  from pathlib import Path
@@ -10,6 +13,7 @@ from unittest.mock import patch
10
13
 
11
14
  REPO = Path(__file__).resolve().parent.parent
12
15
  WRAPUP = REPO / "scripts/wrapup-land.py"
16
+ SETUP = REPO / "scripts/worktree-lifecycle/setup.py"
13
17
 
14
18
 
15
19
  def load_wrapup():
@@ -19,16 +23,557 @@ def load_wrapup():
19
23
  return module
20
24
 
21
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/\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
+
22
176
  class WorktreeCleanupContract(unittest.TestCase):
177
+ def test_profile_scratch_and_generated_evidence_share_safe_removal_contract(self):
178
+ wrapup = load_wrapup()
179
+ with tempfile.TemporaryDirectory() as tmp:
180
+ main, worktree = create_merged_worktree(
181
+ Path(tmp), scratch_patterns=["PLAN.md"]
182
+ )
183
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
184
+ (worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
185
+ generated = worktree / "dist-kit/package.tgz"
186
+ generated.parent.mkdir(parents=True)
187
+ generated.write_text("generated\n", encoding="utf-8")
188
+ evidence = wrapup.landing_verified_scratch_evidence(
189
+ str(worktree), str(main)
190
+ )
191
+ assessment = wrapup.ensure_worktree_removable(
192
+ str(worktree),
193
+ str(main),
194
+ verified_scratch_files=tuple(item["path"] for item in evidence),
195
+ verified_scratch_evidence=evidence,
196
+ )
197
+
198
+ final = wrapup.remove_verified_worktree_scratch(
199
+ str(worktree),
200
+ str(main),
201
+ assessment,
202
+ verified_scratch_evidence=evidence,
203
+ )
204
+
205
+ self.assertFalse(final.reasons)
206
+ self.assertFalse((worktree / "PLAN.md").exists())
207
+ self.assertFalse(generated.exists())
208
+
209
+ def test_profile_scratch_same_path_replacement_is_preserved(self):
210
+ wrapup = load_wrapup()
211
+ with tempfile.TemporaryDirectory() as tmp:
212
+ main, worktree = create_merged_worktree(
213
+ Path(tmp), scratch_patterns=["PLAN.md"]
214
+ )
215
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
216
+ plan = worktree / "PLAN.md"
217
+ plan.write_text("assessed\n", encoding="utf-8")
218
+ assessment = wrapup.ensure_worktree_removable(
219
+ str(worktree), str(main)
220
+ )
221
+ plan.unlink()
222
+ plan.write_text("replacement\n", encoding="utf-8")
223
+ with self.assertRaises(wrapup.Stop) as stopped:
224
+ wrapup.remove_verified_worktree_scratch(
225
+ str(worktree), str(main), assessment
226
+ )
227
+ self.assertIn("inventory no longer matches preview", stopped.exception.reason)
228
+ self.assertEqual(plan.read_text(encoding="utf-8"), "replacement\n")
229
+
230
+ def test_missing_generator_identity_is_not_downgraded_to_profile_scratch(self):
231
+ wrapup = load_wrapup()
232
+ with tempfile.TemporaryDirectory() as tmp:
233
+ main, worktree = create_merged_worktree(Path(tmp))
234
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
235
+ generated = worktree / "dist-kit/package.tgz"
236
+ generated.parent.mkdir(parents=True)
237
+ generated.write_text("generated\n", encoding="utf-8")
238
+ with self.assertRaises(wrapup.Stop) as stopped:
239
+ wrapup.ensure_worktree_removable(
240
+ str(worktree),
241
+ str(main),
242
+ verified_scratch_files=("dist-kit/package.tgz",),
243
+ verified_scratch_evidence=(),
244
+ )
245
+
246
+ self.assertIn("evidence", stopped.exception.detail)
247
+ self.assertTrue(generated.exists())
248
+
249
+ def test_generator_pattern_overlap_still_requires_exact_evidence(self):
250
+ wrapup = load_wrapup()
251
+ with tempfile.TemporaryDirectory() as tmp:
252
+ main, worktree = create_merged_worktree(
253
+ Path(tmp), scratch_patterns=["dist-kit/**"]
254
+ )
255
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
256
+ generated = worktree / "dist-kit/package.tgz"
257
+ generated.parent.mkdir(parents=True)
258
+ generated.write_text("generated\n", encoding="utf-8")
259
+ with self.assertRaises(wrapup.Stop) as stopped:
260
+ wrapup.ensure_worktree_removable(
261
+ str(worktree), str(main), verified_scratch_files=()
262
+ )
263
+ self.assertIn(
264
+ "landing-generated scratch evidence is missing",
265
+ stopped.exception.detail,
266
+ )
267
+ self.assertTrue(generated.exists())
268
+
269
+ def test_legacy_worktree_gets_conservative_baseline_without_claiming_existing_files(self):
270
+ wrapup = load_wrapup()
271
+ with tempfile.TemporaryDirectory() as tmp:
272
+ main, worktree = create_merged_worktree(Path(tmp))
273
+ core = wrapup.load_worktree_cleanup_core()
274
+ core.artifact_baseline_path(worktree).unlink()
275
+ existing = worktree / "dist-kit/existing.tgz"
276
+ existing.parent.mkdir(parents=True)
277
+ existing.write_text("consumer\n", encoding="utf-8")
278
+ preserved = worktree / "consumer/keep.txt"
279
+ preserved.parent.mkdir(parents=True)
280
+ preserved.write_text("preserve\n", encoding="utf-8")
281
+
282
+ with self.assertRaises(wrapup.Stop) as stopped:
283
+ wrapup.landing_start_artifact_inventory(
284
+ str(worktree), str(main)
285
+ )
286
+
287
+ self.assertFalse(core.artifact_baseline_path(worktree).exists())
288
+ self.assertIn("consumer-owned", stopped.exception.reason)
289
+ self.assertTrue(existing.exists())
290
+ self.assertTrue(preserved.exists())
291
+
292
+ existing.unlink()
293
+ attempt = wrapup.landing_start_artifact_inventory(
294
+ str(worktree), str(main)
295
+ )
296
+ baseline = core.load_artifact_baseline(worktree)
297
+ self.assertNotIn("dist-kit/existing.tgz", baseline.initial_ignored_files)
298
+ self.assertIn("consumer/keep.txt", baseline.initial_ignored_files)
299
+ existing.write_text("landing-generated\n", encoding="utf-8")
300
+ evidence = wrapup.landing_verified_scratch_evidence(
301
+ str(worktree),
302
+ str(main),
303
+ expected_baseline_digest=attempt["baselineDigest"],
304
+ landing_start_files=tuple(attempt["generatedFiles"]),
305
+ )
306
+ self.assertEqual(
307
+ [item["path"] for item in evidence],
308
+ ["dist-kit/existing.tgz"],
309
+ )
310
+
311
+ def test_preexisting_generated_blocker_does_not_poison_next_attempt(self):
312
+ wrapup = load_wrapup()
313
+ with tempfile.TemporaryDirectory() as tmp:
314
+ main, worktree = create_merged_worktree(Path(tmp))
315
+ blocker = worktree / "dist-kit/preexisting.tgz"
316
+ blocker.parent.mkdir(parents=True)
317
+ blocker.write_text("consumer\n", encoding="utf-8")
318
+ core = wrapup.load_worktree_cleanup_core()
319
+ attempt_path = core.artifact_baseline_path(worktree).with_name(
320
+ core.LANDING_ATTEMPT_FILE
321
+ )
322
+
323
+ with self.assertRaises(wrapup.Stop):
324
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
325
+ self.assertFalse(attempt_path.exists())
326
+ blocker.unlink()
327
+ attempt = wrapup.landing_start_artifact_inventory(
328
+ str(worktree), str(main)
329
+ )
330
+ self.assertTrue(attempt["newAttempt"])
331
+
332
+ def test_candidate_policy_blocks_generated_path_before_canonical_bootstrap(self):
333
+ wrapup = load_wrapup()
334
+ with tempfile.TemporaryDirectory() as tmp:
335
+ main, worktree = create_merged_worktree(Path(tmp))
336
+ core = wrapup.load_worktree_cleanup_core()
337
+ baseline_path = core.artifact_baseline_path(worktree)
338
+ baseline_path.unlink()
339
+ canonical_profile = main / "docs/agents/workflow-capabilities.json"
340
+ canonical = json.loads(canonical_profile.read_text(encoding="utf-8"))
341
+ del canonical["wrapup"]
342
+ canonical_profile.write_text(json.dumps(canonical), encoding="utf-8")
343
+ blocker = worktree / "dist-kit/bootstrap.tgz"
344
+ blocker.parent.mkdir(parents=True)
345
+ blocker.write_text("preserve\n", encoding="utf-8")
346
+
347
+ with self.assertRaises(wrapup.Stop) as stopped:
348
+ wrapup.landing_start_artifact_inventory(
349
+ str(worktree), str(main)
350
+ )
351
+
352
+ self.assertIn(
353
+ "landing-start generated paths are consumer-owned",
354
+ stopped.exception.reason,
355
+ )
356
+ self.assertFalse(baseline_path.exists())
357
+ self.assertFalse(
358
+ baseline_path.with_name(core.LANDING_ATTEMPT_FILE).exists()
359
+ )
360
+
361
+ def test_candidate_policy_cannot_widen_post_merge_deletion_authority(self):
362
+ wrapup = load_wrapup()
363
+ with tempfile.TemporaryDirectory() as tmp:
364
+ main, worktree = create_merged_worktree(Path(tmp))
365
+ candidate_path = worktree / "docs/agents/workflow-capabilities.json"
366
+ candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
367
+ candidate["wrapup"]["landingGeneratedArtifactPatterns"].append("**")
368
+ candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
369
+ command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
370
+ command(["git", "commit", "-m", "widen candidate policy"], worktree)
371
+ core = wrapup.load_worktree_cleanup_core()
372
+ attempt_path = core.artifact_baseline_path(worktree).with_name(
373
+ core.LANDING_ATTEMPT_FILE
374
+ )
375
+
376
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
377
+ with self.assertRaises(wrapup.Stop) as stopped:
378
+ wrapup.ensure_worktree_removable(str(worktree), str(main))
379
+
380
+ self.assertIn(
381
+ "worktree cleanup policy differs from merged canonical origin/main",
382
+ stopped.exception.reason,
383
+ )
384
+ self.assertTrue(attempt_path.exists())
385
+
386
+ def test_policy_drift_during_attempt_cannot_claim_preexisting_consumer_file(self):
387
+ wrapup = load_wrapup()
388
+ with tempfile.TemporaryDirectory() as tmp:
389
+ main, worktree = create_merged_worktree(Path(tmp))
390
+ victim = worktree / "consumer/victim.txt"
391
+ victim.parent.mkdir(parents=True)
392
+ victim.write_text("preserve\n", encoding="utf-8")
393
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
394
+ candidate_path = worktree / "docs/agents/workflow-capabilities.json"
395
+ candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
396
+ candidate["wrapup"]["landingGeneratedArtifactPatterns"].append(
397
+ "consumer/**"
398
+ )
399
+ candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
400
+ command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
401
+ command(["git", "commit", "-m", "widen active attempt policy"], worktree)
402
+
403
+ with self.assertRaises(wrapup.Stop) as stopped:
404
+ wrapup.freeze_landing_artifact_evidence(
405
+ str(worktree), str(main), push_succeeded=True
406
+ )
407
+
408
+ self.assertIn(
409
+ "landing cleanup policy changed after attempt start",
410
+ stopped.exception.reason,
411
+ )
412
+ self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
413
+
414
+ def test_canonical_cleanup_rejects_generator_evidence_outside_policy(self):
415
+ wrapup = load_wrapup()
416
+ with tempfile.TemporaryDirectory() as tmp:
417
+ main, worktree = create_merged_worktree(Path(tmp))
418
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
419
+ victim = worktree / "consumer/victim.txt"
420
+ victim.parent.mkdir(parents=True)
421
+ victim.write_text("preserve\n", encoding="utf-8")
422
+ core = wrapup.load_worktree_cleanup_core()
423
+ baseline = core.load_artifact_baseline(worktree)
424
+ with core.verified_worktree_root(
425
+ worktree, baseline.root_device, baseline.root_inode
426
+ ) as descriptor:
427
+ evidence = (
428
+ core.contained_regular_identity(
429
+ descriptor, "consumer/victim.txt"
430
+ ),
431
+ )
432
+
433
+ with self.assertRaises(wrapup.Stop) as stopped:
434
+ wrapup.ensure_worktree_removable(
435
+ str(worktree),
436
+ str(main),
437
+ verified_scratch_evidence=evidence,
438
+ )
439
+
440
+ self.assertIn(
441
+ "generator evidence is outside canonical landing policy",
442
+ stopped.exception.detail,
443
+ )
444
+ self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
445
+
446
+ def test_missing_local_main_profile_cannot_disable_canonical_guard(self):
447
+ wrapup = load_wrapup()
448
+ with tempfile.TemporaryDirectory() as tmp:
449
+ main, worktree = create_merged_worktree(Path(tmp))
450
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
451
+ wrapup.freeze_landing_artifact_evidence(
452
+ str(worktree), str(main), push_succeeded=True
453
+ )
454
+ (main / "docs/agents/workflow-capabilities.json").unlink()
455
+ victim = worktree / "consumer/ignored.txt"
456
+ victim.parent.mkdir(parents=True)
457
+ victim.write_text("preserve\n", encoding="utf-8")
458
+
459
+ with self.assertRaises(wrapup.Stop) as stopped:
460
+ wrapup.ensure_worktree_removable(str(worktree), str(main))
461
+
462
+ self.assertIn(
463
+ "dirty worktree: untracked non-scratch: consumer/ignored.txt",
464
+ stopped.exception.detail,
465
+ )
466
+ self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
467
+ self.assertTrue(worktree.is_dir())
468
+
469
+ def test_explicit_empty_landing_policy_is_distinct_from_missing(self):
470
+ wrapup = load_wrapup()
471
+ core = wrapup.load_worktree_cleanup_core()
472
+ with tempfile.TemporaryDirectory() as tmp:
473
+ profile_path = Path(tmp) / "workflow-capabilities.json"
474
+ document = {
475
+ "worktreeLifecycle": {"enabled": True},
476
+ "wrapup": {"landingGeneratedArtifactPatterns": []},
477
+ }
478
+ profile_path.write_text(json.dumps(document), encoding="utf-8")
479
+ configured = core.load_profile(profile_path)
480
+ del document["wrapup"]
481
+ profile_path.write_text(json.dumps(document), encoding="utf-8")
482
+ missing = core.load_profile(profile_path)
483
+
484
+ self.assertTrue(
485
+ configured.landing_generated_artifact_policy_configured
486
+ )
487
+ self.assertEqual(configured.landing_generated_artifact_patterns, ())
488
+ self.assertFalse(missing.landing_generated_artifact_policy_configured)
489
+
490
+ def test_abandon_archives_drifted_frozen_attempt_without_touching_files(self):
491
+ wrapup = load_wrapup()
492
+ with tempfile.TemporaryDirectory() as tmp:
493
+ main, worktree = create_merged_worktree(Path(tmp))
494
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
495
+ generated = worktree / "dist-kit/package.tgz"
496
+ generated.parent.mkdir(parents=True)
497
+ generated.write_text("one\n", encoding="utf-8")
498
+ wrapup.freeze_landing_artifact_evidence(
499
+ str(worktree), str(main), push_succeeded=True
500
+ )
501
+ generated.write_text("two\n", encoding="utf-8")
502
+
503
+ args = SimpleNamespace(
504
+ branch="fix/268-cleanup",
505
+ body_file=None,
506
+ title=None,
507
+ anchor=None,
508
+ skip_malformed_drift=False,
509
+ abandon_unfinished_attempt=True,
510
+ )
511
+ previous = Path.cwd()
512
+ try:
513
+ os.chdir(main)
514
+ result = wrapup.cmd_land(args)
515
+ finally:
516
+ os.chdir(previous)
517
+ archive = Path(result["landing_attempt_abandoned"])
518
+
519
+ self.assertTrue(archive.is_file())
520
+ self.assertEqual(generated.read_text(encoding="utf-8"), "two\n")
521
+ with self.assertRaises(wrapup.Stop):
522
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
523
+
524
+ def test_cli_abandon_archives_attempt_even_when_baseline_is_missing(self):
525
+ wrapup = load_wrapup()
526
+ with tempfile.TemporaryDirectory() as tmp:
527
+ main, worktree = create_merged_worktree(Path(tmp))
528
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
529
+ generated = worktree / "dist-kit/ambiguous.tgz"
530
+ generated.parent.mkdir(parents=True)
531
+ generated.write_text("keep\n", encoding="utf-8")
532
+ core = wrapup.load_worktree_cleanup_core()
533
+ core.artifact_baseline_path(worktree).unlink()
534
+ args = SimpleNamespace(
535
+ branch="fix/268-cleanup",
536
+ body_file=None,
537
+ title=None,
538
+ anchor=None,
539
+ skip_malformed_drift=False,
540
+ abandon_unfinished_attempt=True,
541
+ )
542
+ previous = Path.cwd()
543
+ try:
544
+ os.chdir(main)
545
+ result = wrapup.cmd_land(args)
546
+ finally:
547
+ os.chdir(previous)
548
+ self.assertTrue(Path(result["landing_attempt_abandoned"]).is_file())
549
+ self.assertEqual(generated.read_text(encoding="utf-8"), "keep\n")
550
+
551
+ def test_profile_globs_match_root_and_nested_without_star_crossing_slash(self):
552
+ wrapup = load_wrapup()
553
+ core = wrapup.load_worktree_cleanup_core()
554
+ self.assertTrue(core.path_glob_matches("__pycache__/a.pyc", "**/__pycache__/**"))
555
+ self.assertTrue(core.path_glob_matches("src/__pycache__/a.pyc", "**/__pycache__/**"))
556
+ self.assertTrue(core.path_glob_matches("dist-kit/a", "dist-kit/**"))
557
+ self.assertTrue(core.path_glob_matches("dist-kit/a/b", "dist-kit/**"))
558
+ self.assertFalse(core.path_glob_matches("dist-kit/a/b", "dist-kit/*"))
559
+ self.assertTrue(core.path_glob_matches("cache/7.tmp", "cache/[0-9].tmp"))
560
+ self.assertFalse(core.path_glob_matches("cache/x.tmp", "cache/[0-9].tmp"))
561
+
23
562
  def test_active_profile_delegates_removal_safety_to_shared_assessment(self):
24
563
  wrapup = load_wrapup()
25
564
  calls = []
26
565
 
27
566
  class FakeCore:
567
+ LifecycleError = RuntimeError
568
+
28
569
  @staticmethod
29
570
  def load_profile(path):
30
571
  calls.append(("profile", path))
31
- return {"enabled": True}
572
+ return SimpleNamespace(
573
+ landing_generated_artifact_policy_configured=True,
574
+ landing_generated_artifact_patterns=(),
575
+ scratch_patterns=(),
576
+ )
32
577
 
33
578
  @staticmethod
34
579
  def cleanup_assessment(profile, main, target, merge_target=None):
@@ -40,12 +585,468 @@ class WorktreeCleanupContract(unittest.TestCase):
40
585
  profile = main / "docs/agents/workflow-capabilities.json"
41
586
  profile.parent.mkdir(parents=True)
42
587
  profile.write_text('{"worktreeLifecycle":{"enabled":true}}\n')
43
- with patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore):
588
+ with (
589
+ patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore),
590
+ patch.object(
591
+ wrapup,
592
+ "load_canonical_landing_profile",
593
+ return_value=SimpleNamespace(),
594
+ ),
595
+ ):
44
596
  with self.assertRaises(wrapup.Stop) as stopped:
45
597
  wrapup.ensure_worktree_removable(str(main / "wt"), str(main))
46
598
 
47
599
  self.assertIn("shared cleanup guard", stopped.exception.reason)
48
- self.assertEqual(calls[1][-1], "origin/main")
600
+ self.assertEqual(calls[-1][-1], "origin/main")
601
+
602
+ def test_real_build_and_python_check_after_baseline_are_cleaned_by_land(self):
603
+ wrapup = load_wrapup()
604
+ with tempfile.TemporaryDirectory() as tmp:
605
+ root = Path(tmp)
606
+ main, worktree = create_real_kit_merged_worktree(root)
607
+ appendable = worktree / ".claude/logs/session.log"
608
+ appendable.parent.mkdir(parents=True, exist_ok=True)
609
+ appendable.write_text("appendable\n", encoding="utf-8")
610
+ (worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
611
+ # The first landing attempt journals its start before the real
612
+ # pre-push generators run. A resumed already-MERGED land must
613
+ # reuse that attempt instead of treating its outputs as user files.
614
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
615
+ command(["npm", "run", "kit:build"], worktree)
616
+ command([
617
+ os.sys.executable,
618
+ "-m",
619
+ "py_compile",
620
+ "scripts/worktree-lifecycle/core.py",
621
+ ], worktree)
622
+ wrapup.freeze_landing_artifact_evidence(
623
+ str(worktree),
624
+ str(main),
625
+ push_succeeded=True,
626
+ )
627
+ self.assertTrue((worktree / "dist-kit/package.json").is_file())
628
+ self.assertTrue(
629
+ any(
630
+ (worktree / "scripts/worktree-lifecycle/__pycache__").glob(
631
+ "core.*.pyc"
632
+ )
633
+ )
634
+ )
635
+
636
+ real_run = wrapup.run
637
+
638
+ def landing_run(args, cwd=None, check=False):
639
+ if args[:3] == ["gh", "pr", "view"]:
640
+ fields = args[-1]
641
+ payload = (
642
+ {"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
643
+ if "number,state,body" in fields
644
+ else {"state": "MERGED"}
645
+ )
646
+ return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
647
+ if args[:3] == ["gh", "issue", "view"]:
648
+ return subprocess.CompletedProcess(
649
+ args, 0, json.dumps({"state": "CLOSED"}), ""
650
+ )
651
+ if args[:3] == ["gh", "pr", "list"]:
652
+ return subprocess.CompletedProcess(args, 0, "", "")
653
+ if args and args[0] == os.sys.executable:
654
+ return subprocess.CompletedProcess(args, 0, "", "")
655
+ return real_run(args, cwd=cwd, check=check)
656
+
657
+ args = SimpleNamespace(
658
+ branch="fix/268-real-generator",
659
+ body_file=None,
660
+ title=None,
661
+ anchor=None,
662
+ skip_malformed_drift=False,
663
+ )
664
+ previous = Path.cwd()
665
+ try:
666
+ os.chdir(main)
667
+ with (
668
+ patch.object(wrapup, "run", side_effect=landing_run),
669
+ patch.object(wrapup, "wait_for_merge_gate", return_value=True),
670
+ patch.object(wrapup, "kill_worktree_processes", return_value=[]),
671
+ ):
672
+ first = wrapup.cmd_land(args)
673
+ second = wrapup.cmd_land(args)
674
+ finally:
675
+ os.chdir(previous)
676
+
677
+ generated = first["cleanup_guard"]["landing_generated_files"]
678
+ self.assertIn("dist-kit/package.json", generated)
679
+ self.assertTrue(
680
+ any(
681
+ path.startswith(
682
+ "scripts/worktree-lifecycle/__pycache__/core."
683
+ )
684
+ and path.endswith(".pyc")
685
+ for path in generated
686
+ ),
687
+ )
688
+ self.assertEqual(first["worktree_removed"], str(worktree))
689
+ self.assertFalse(worktree.exists())
690
+ self.assertTrue(second["merged"])
691
+
692
+ def test_append_after_profile_log_assessment_is_preserved_and_stops(self):
693
+ wrapup = load_wrapup()
694
+ with tempfile.TemporaryDirectory() as tmp:
695
+ main, worktree = create_merged_worktree(
696
+ Path(tmp), scratch_patterns=[".claude/logs/**"]
697
+ )
698
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
699
+ log = worktree / ".claude/logs/session.log"
700
+ log.parent.mkdir(parents=True)
701
+ log.write_text("assessed\n", encoding="utf-8")
702
+ assessment = wrapup.ensure_worktree_removable(
703
+ str(worktree), str(main)
704
+ )
705
+ with log.open("a", encoding="utf-8") as handle:
706
+ handle.write("late\n")
707
+ with self.assertRaises(wrapup.Stop):
708
+ wrapup.remove_verified_worktree_scratch(
709
+ str(worktree), str(main), assessment
710
+ )
711
+ self.assertEqual(log.read_text(encoding="utf-8"), "assessed\nlate\n")
712
+
713
+ def test_profile_generated_path_present_in_creation_baseline_is_not_scratch(self):
714
+ wrapup = load_wrapup()
715
+ setup_step = {
716
+ "kind": "command",
717
+ "command": [
718
+ os.sys.executable,
719
+ "-c",
720
+ (
721
+ "from pathlib import Path; "
722
+ "p=Path('dist-kit/setup-owned.txt'); "
723
+ "p.parent.mkdir(parents=True); p.write_text('initial')"
724
+ ),
725
+ ],
726
+ }
727
+ with tempfile.TemporaryDirectory() as tmp:
728
+ main, worktree = create_merged_worktree(
729
+ Path(tmp),
730
+ setup_steps=[setup_step],
731
+ )
732
+
733
+ with self.assertRaises(wrapup.Stop) as stopped:
734
+ wrapup.landing_verified_scratch_files(
735
+ str(worktree),
736
+ str(main),
737
+ )
738
+
739
+ self.assertIn("dist-kit/setup-owned.txt", stopped.exception.reason)
740
+
741
+ def test_missing_creation_baseline_fails_safe(self):
742
+ wrapup = load_wrapup()
743
+ with tempfile.TemporaryDirectory() as tmp:
744
+ main, worktree = create_merged_worktree(Path(tmp))
745
+ core = wrapup.load_worktree_cleanup_core()
746
+ core.artifact_baseline_path(worktree).unlink()
747
+
748
+ with self.assertRaises(wrapup.Stop) as stopped:
749
+ wrapup.landing_verified_scratch_files(
750
+ str(worktree),
751
+ str(main),
752
+ )
753
+
754
+ self.assertIn("artifact provenance baseline", stopped.exception.reason)
755
+
756
+ def test_incoherent_creation_baseline_fails_safe(self):
757
+ wrapup = load_wrapup()
758
+ with tempfile.TemporaryDirectory() as tmp:
759
+ main, worktree = create_merged_worktree(Path(tmp))
760
+ core = wrapup.load_worktree_cleanup_core()
761
+ baseline_path = core.artifact_baseline_path(worktree)
762
+ baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
763
+ baseline["branch"] = "fix/999-foreign"
764
+ baseline_path.write_text(json.dumps(baseline), encoding="utf-8")
765
+
766
+ with self.assertRaises(wrapup.Stop) as stopped:
767
+ wrapup.landing_verified_scratch_files(
768
+ str(worktree),
769
+ str(main),
770
+ )
771
+
772
+ self.assertIn("artifact provenance baseline", stopped.exception.reason)
773
+
774
+ def test_arbitrary_ignored_file_stops_exact_landing_cleanup(self):
775
+ wrapup = load_wrapup()
776
+ with tempfile.TemporaryDirectory() as tmp:
777
+ main, worktree = create_merged_worktree(Path(tmp))
778
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
779
+ generated = worktree / "dist-kit/package.tgz"
780
+ generated.parent.mkdir(parents=True)
781
+ generated.write_text("package", encoding="utf-8")
782
+ consumer = worktree / "consumer/private.cache"
783
+ consumer.parent.mkdir(parents=True)
784
+ consumer.write_text("mine", encoding="utf-8")
785
+
786
+ with self.assertRaises(wrapup.Stop) as stopped:
787
+ wrapup.ensure_worktree_removable(
788
+ str(worktree),
789
+ str(main),
790
+ verified_scratch_files=("dist-kit/package.tgz",),
791
+ )
792
+
793
+ self.assertIn("consumer/private.cache", stopped.exception.detail)
794
+ self.assertTrue(generated.exists())
795
+ self.assertTrue(consumer.exists())
796
+
797
+ def test_symlink_in_generated_evidence_stops_before_foreign_target_is_touched(self):
798
+ wrapup = load_wrapup()
799
+ with tempfile.TemporaryDirectory() as tmp:
800
+ root = Path(tmp)
801
+ main, worktree = create_merged_worktree(root)
802
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
803
+ foreign = root / "foreign.txt"
804
+ foreign.write_text("keep", encoding="utf-8")
805
+ generated = worktree / "dist-kit/package.tgz"
806
+ generated.parent.mkdir(parents=True)
807
+ generated.symlink_to(foreign)
808
+ with self.assertRaises(wrapup.Stop) as stopped:
809
+ wrapup.ensure_worktree_removable(
810
+ str(worktree),
811
+ str(main),
812
+ verified_scratch_files=("dist-kit/package.tgz",),
813
+ )
814
+
815
+ self.assertIn("evidence is missing", stopped.exception.detail)
816
+ self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
817
+ self.assertTrue(generated.is_symlink())
818
+
819
+ def test_late_generated_pattern_write_is_not_added_to_verified_evidence(self):
820
+ wrapup = load_wrapup()
821
+ with tempfile.TemporaryDirectory() as tmp:
822
+ main, worktree = create_merged_worktree(Path(tmp))
823
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
824
+ generated = worktree / "dist-kit/package.tgz"
825
+ generated.parent.mkdir(parents=True)
826
+ generated.write_text("package", encoding="utf-8")
827
+ evidence = wrapup.landing_verified_scratch_evidence(
828
+ str(worktree),
829
+ str(main),
830
+ landing_start_files=(),
831
+ )
832
+ assessment = wrapup.ensure_worktree_removable(
833
+ str(worktree),
834
+ str(main),
835
+ verified_scratch_evidence=evidence,
836
+ )
837
+ late = worktree / "dist-kit/late.tgz"
838
+ late.parent.mkdir(parents=True, exist_ok=True)
839
+ late.write_text("late", encoding="utf-8")
840
+
841
+ with self.assertRaises(wrapup.Stop) as stopped:
842
+ wrapup.remove_verified_worktree_scratch(
843
+ str(worktree),
844
+ str(main),
845
+ assessment,
846
+ verified_scratch_evidence=evidence,
847
+ )
848
+
849
+ self.assertIn("dist-kit/late.tgz", stopped.exception.reason)
850
+ self.assertTrue(generated.exists())
851
+ self.assertTrue(late.exists())
852
+
853
+ def test_same_path_generated_replacement_is_preserved_by_frozen_evidence(self):
854
+ wrapup = load_wrapup()
855
+ with tempfile.TemporaryDirectory() as tmp:
856
+ main, worktree = create_merged_worktree(Path(tmp))
857
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
858
+ generated = worktree / "dist-kit/package.tgz"
859
+ generated.parent.mkdir(parents=True)
860
+ generated.write_text("generator-owned", encoding="utf-8")
861
+ evidence = wrapup.landing_verified_scratch_evidence(
862
+ str(worktree),
863
+ str(main),
864
+ landing_start_files=(),
865
+ )
866
+ assessment = wrapup.ensure_worktree_removable(
867
+ str(worktree),
868
+ str(main),
869
+ verified_scratch_evidence=evidence,
870
+ )
871
+ generated.unlink()
872
+ generated.write_text("user replacement", encoding="utf-8")
873
+
874
+ with self.assertRaises(wrapup.Stop) as stopped:
875
+ wrapup.remove_verified_worktree_scratch(
876
+ str(worktree),
877
+ str(main),
878
+ assessment,
879
+ verified_scratch_evidence=evidence,
880
+ )
881
+
882
+ self.assertIn("identity changed", stopped.exception.reason)
883
+ self.assertEqual(generated.read_text(encoding="utf-8"), "user replacement")
884
+
885
+ def test_landing_start_generated_file_is_rejected_before_attempt_is_written(self):
886
+ wrapup = load_wrapup()
887
+ with tempfile.TemporaryDirectory() as tmp:
888
+ main, worktree = create_merged_worktree(Path(tmp))
889
+ user_file = worktree / "dist-kit/user-note.txt"
890
+ user_file.parent.mkdir(parents=True)
891
+ user_file.write_text("keep", encoding="utf-8")
892
+ with self.assertRaises(wrapup.Stop) as stopped:
893
+ wrapup.landing_start_artifact_inventory(
894
+ str(worktree), str(main)
895
+ )
896
+
897
+ self.assertIn("landing-start generated paths", stopped.exception.reason)
898
+ self.assertTrue(user_file.exists())
899
+ core = wrapup.load_worktree_cleanup_core()
900
+ self.assertFalse(
901
+ core.artifact_baseline_path(worktree)
902
+ .with_name(core.LANDING_ATTEMPT_FILE)
903
+ .exists()
904
+ )
905
+
906
+ def test_frozen_landing_output_does_not_claim_a_later_private_path(self):
907
+ wrapup = load_wrapup()
908
+ with tempfile.TemporaryDirectory() as tmp:
909
+ main, worktree = create_merged_worktree(Path(tmp))
910
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
911
+ generated = worktree / "dist-kit/package.tgz"
912
+ generated.parent.mkdir(parents=True)
913
+ generated.write_text("generator", encoding="utf-8")
914
+ wrapup.freeze_landing_artifact_evidence(
915
+ str(worktree), str(main), push_succeeded=True
916
+ )
917
+ private = worktree / "dist-kit/private.log"
918
+ private.parent.mkdir(parents=True, exist_ok=True)
919
+ private.write_text("keep", encoding="utf-8")
920
+
921
+ with self.assertRaises(wrapup.Stop) as stopped:
922
+ wrapup.freeze_landing_artifact_evidence(
923
+ str(worktree), str(main), push_succeeded=True
924
+ )
925
+
926
+ self.assertIn("evidence changed", stopped.exception.reason)
927
+ self.assertTrue(generated.exists())
928
+ self.assertTrue(private.exists())
929
+
930
+ def test_failed_push_retry_validates_replacement_before_invoking_push(self):
931
+ wrapup = load_wrapup()
932
+ with tempfile.TemporaryDirectory() as tmp:
933
+ main, worktree = create_merged_worktree(Path(tmp))
934
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
935
+ generated = worktree / "dist-kit/package.tgz"
936
+ generated.parent.mkdir(parents=True)
937
+ generated.write_text("generator", encoding="utf-8")
938
+ wrapup.freeze_landing_artifact_evidence(
939
+ str(worktree), str(main), push_succeeded=False
940
+ )
941
+ generated.unlink()
942
+ generated.write_text("user replacement", encoding="utf-8")
943
+ pushes = []
944
+ real_git = wrapup.git
945
+
946
+ def observed_git(args, **kwargs):
947
+ if args and args[0] == "push":
948
+ pushes.append(args)
949
+ return real_git(args, **kwargs)
950
+
951
+ args = SimpleNamespace(
952
+ branch="fix/268-cleanup",
953
+ body_file=None,
954
+ title=None,
955
+ anchor=None,
956
+ skip_malformed_drift=False,
957
+ abandon_unfinished_attempt=False,
958
+ )
959
+ previous = Path.cwd()
960
+ try:
961
+ os.chdir(main)
962
+ with (
963
+ patch.object(wrapup, "git", side_effect=observed_git),
964
+ self.assertRaises(wrapup.Stop) as stopped,
965
+ ):
966
+ wrapup.cmd_land(args)
967
+ finally:
968
+ os.chdir(previous)
969
+
970
+ self.assertIn("evidence changed", stopped.exception.reason)
971
+ self.assertEqual(pushes, [])
972
+ self.assertEqual(
973
+ generated.read_text(encoding="utf-8"), "user replacement"
974
+ )
975
+
976
+ def test_first_attempt_protects_preexisting_generated_path_before_push(self):
977
+ wrapup = load_wrapup()
978
+ with tempfile.TemporaryDirectory() as tmp:
979
+ main, worktree = create_merged_worktree(Path(tmp))
980
+ user_file = worktree / "dist-kit/package.json"
981
+ user_file.parent.mkdir(parents=True)
982
+ user_file.write_text("user bytes", encoding="utf-8")
983
+ pushes = []
984
+ real_git = wrapup.git
985
+
986
+ def observed_git(args, **kwargs):
987
+ if args and args[0] == "push":
988
+ pushes.append(args)
989
+ return real_git(args, **kwargs)
990
+
991
+ args = SimpleNamespace(
992
+ branch="fix/268-cleanup",
993
+ body_file=None,
994
+ title=None,
995
+ anchor=None,
996
+ skip_malformed_drift=False,
997
+ abandon_unfinished_attempt=False,
998
+ )
999
+ previous = Path.cwd()
1000
+ try:
1001
+ os.chdir(main)
1002
+ with (
1003
+ patch.object(wrapup, "git", side_effect=observed_git),
1004
+ self.assertRaises(wrapup.Stop) as stopped,
1005
+ ):
1006
+ wrapup.cmd_land(args)
1007
+ finally:
1008
+ os.chdir(previous)
1009
+
1010
+ self.assertIn("consumer-owned", stopped.exception.reason)
1011
+ self.assertEqual(pushes, [])
1012
+ self.assertEqual(user_file.read_text(encoding="utf-8"), "user bytes")
1013
+
1014
+ def test_unfinished_attempt_can_be_archived_without_claiming_ambiguous_files(self):
1015
+ wrapup = load_wrapup()
1016
+ with tempfile.TemporaryDirectory() as tmp:
1017
+ main, worktree = create_merged_worktree(Path(tmp))
1018
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1019
+ core = wrapup.load_worktree_cleanup_core()
1020
+ baseline_path = core.artifact_baseline_path(worktree)
1021
+ attempt_path = baseline_path.with_name(core.LANDING_ATTEMPT_FILE)
1022
+ attempt = json.loads(attempt_path.read_text(encoding="utf-8"))
1023
+ payload = {
1024
+ key: value
1025
+ for key, value in attempt.items()
1026
+ if key not in {"policyDigest", "sha256"}
1027
+ }
1028
+ payload["contractVersion"] = 1
1029
+ attempt_path.write_text(
1030
+ json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
1031
+ encoding="utf-8",
1032
+ )
1033
+ baseline_path.unlink()
1034
+ ambiguous = worktree / "dist-kit/ambiguous.tgz"
1035
+ ambiguous.parent.mkdir(parents=True)
1036
+ ambiguous.write_text("unknown owner", encoding="utf-8")
1037
+ (main / "docs/agents/workflow-capabilities.json").unlink()
1038
+
1039
+ archive = Path(wrapup.abandon_unfinished_landing_attempt(
1040
+ str(worktree), str(main)
1041
+ ))
1042
+
1043
+ self.assertTrue(archive.is_file())
1044
+ self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
1045
+ with self.assertRaises(wrapup.Stop) as stopped:
1046
+ wrapup.landing_start_artifact_inventory(
1047
+ str(worktree), str(main)
1048
+ )
1049
+ self.assertIn("consumer-owned", stopped.exception.reason)
49
1050
 
50
1051
 
51
1052
  if __name__ == "__main__":