@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
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ """Focused contracts for wrapup's bounded PR-check merge gate."""
3
+
4
+ import importlib.util
5
+ import io
6
+ import json
7
+ import subprocess
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ REPO = Path(__file__).resolve().parent.parent
12
+ WRAPUP = REPO / "scripts/wrapup-land.py"
13
+
14
+
15
+ def load_wrapup():
16
+ spec = importlib.util.spec_from_file_location("wrapup_land_check_gate", WRAPUP)
17
+ module = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ def completed(payload, returncode=0, stderr=""):
23
+ return subprocess.CompletedProcess([], returncode, json.dumps(payload), stderr)
24
+
25
+
26
+ def pr_snapshot(**overrides):
27
+ snapshot = {
28
+ "state": "OPEN",
29
+ "mergeable": "MERGEABLE",
30
+ "mergeStateStatus": "CLEAN",
31
+ "statusCheckRollup": [],
32
+ "baseRefName": "main",
33
+ }
34
+ snapshot.update(overrides)
35
+ return snapshot
36
+
37
+
38
+ def gate_runner(snapshots, required_checks):
39
+ snapshots = iter(snapshots)
40
+ required_checks = iter(required_checks)
41
+
42
+ def runner(cmd, **_kwargs):
43
+ if cmd[:3] == ["gh", "pr", "view"]:
44
+ return completed(next(snapshots))
45
+ if cmd[:3] == ["gh", "pr", "checks"]:
46
+ checks = next(required_checks)
47
+ return completed(checks, returncode=1 if any(
48
+ check.get("state") == "FAILURE" for check in checks
49
+ ) else 0)
50
+ raise AssertionError(f"unexpected command: {cmd}")
51
+
52
+ return runner
53
+
54
+
55
+ class FakeClock:
56
+ def __init__(self):
57
+ self.now = 0.0
58
+
59
+ def monotonic(self):
60
+ return self.now
61
+
62
+ def sleep(self, seconds):
63
+ self.now += seconds
64
+
65
+
66
+ class WrapupCheckGateContract(unittest.TestCase):
67
+ def setUp(self):
68
+ self.wrapup = load_wrapup()
69
+
70
+ def test_required_check_names_come_from_active_base_branch_rules(self):
71
+ commands = []
72
+
73
+ def runner(cmd, **_kwargs):
74
+ commands.append(cmd)
75
+ if cmd[:3] == ["gh", "repo", "view"]:
76
+ return completed({"nameWithOwner": "acme/repo"})
77
+ if cmd[:2] == ["gh", "api"]:
78
+ return completed([
79
+ {"type": "pull_request"},
80
+ {
81
+ "type": "required_status_checks",
82
+ "parameters": {
83
+ "required_status_checks": [
84
+ {"context": "test", "integration_id": 1},
85
+ {"context": "lint", "integration_id": 1},
86
+ ],
87
+ },
88
+ },
89
+ ])
90
+ raise AssertionError(f"unexpected command: {cmd}")
91
+
92
+ self.assertEqual(
93
+ self.wrapup._configured_required_check_names(
94
+ pr_snapshot(baseRefName="release/next"), runner
95
+ ),
96
+ {"test", "lint"},
97
+ )
98
+ self.assertIn(
99
+ ["gh", "api", "repos/acme/repo/rules/branches/release%2Fnext"],
100
+ commands,
101
+ )
102
+
103
+ def test_pending_checks_are_visible_then_green_checks_proceed(self):
104
+ snapshots = iter([
105
+ pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
106
+ pr_snapshot(),
107
+ ])
108
+ checks = iter([
109
+ [{"name": "test", "state": "PENDING", "link": "https://example.test"}],
110
+ [{"name": "test", "state": "SUCCESS", "link": "https://example.test"}],
111
+ ])
112
+ calls = []
113
+
114
+ def runner(cmd, **_kwargs):
115
+ calls.append(cmd)
116
+ if cmd[:3] == ["gh", "pr", "view"]:
117
+ return completed(next(snapshots))
118
+ if cmd[:3] == ["gh", "pr", "checks"]:
119
+ return completed(next(checks))
120
+ raise AssertionError(f"unexpected command: {cmd}")
121
+
122
+ clock = FakeClock()
123
+ progress = io.StringIO()
124
+ already_merged = self.wrapup.wait_for_merge_gate(
125
+ "42",
126
+ timeout_seconds=30,
127
+ poll_interval=5,
128
+ command_runner=runner,
129
+ clock=clock.monotonic,
130
+ sleeper=clock.sleep,
131
+ progress_stream=progress,
132
+ configured_required_names={"test"},
133
+ )
134
+
135
+ self.assertFalse(already_merged)
136
+ self.assertEqual(len(calls), 4)
137
+ self.assertIn("waiting for PR #42 checks", progress.getvalue())
138
+ self.assertIn("test", progress.getvalue())
139
+
140
+ def test_explicit_null_conclusion_waits_even_with_green_legacy_state(self):
141
+ checks = [{
142
+ "name": "test",
143
+ "status": "COMPLETED",
144
+ "conclusion": None,
145
+ "state": "SUCCESS",
146
+ }]
147
+
148
+ self.assertEqual(self.wrapup.pending_checks(checks), checks)
149
+
150
+ def test_terminal_red_check_stops_before_merge_and_names_check(self):
151
+ snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
152
+ checks = [[{
153
+ "name": "test",
154
+ "state": "FAILURE",
155
+ "link": "https://example.test",
156
+ }]]
157
+
158
+ with self.assertRaises(self.wrapup.Stop) as stopped:
159
+ self.wrapup.wait_for_merge_gate(
160
+ "42",
161
+ command_runner=gate_runner([snapshot], checks),
162
+ configured_required_names={"test"},
163
+ )
164
+
165
+ self.assertEqual(stopped.exception.step, "0c merge-gate")
166
+ self.assertIn("test", stopped.exception.detail)
167
+ self.assertNotIn("infrastructure failure", stopped.exception.detail)
168
+
169
+ def test_timeout_names_pending_checks_and_elapsed_time(self):
170
+ snapshot = pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED")
171
+ checks = [
172
+ {"name": "test", "state": "QUEUED"},
173
+ {"name": "lint", "state": "PENDING"},
174
+ ]
175
+ clock = FakeClock()
176
+
177
+ with self.assertRaises(self.wrapup.Stop) as stopped:
178
+ self.wrapup.wait_for_merge_gate(
179
+ "42",
180
+ timeout_seconds=10,
181
+ poll_interval=5,
182
+ command_runner=gate_runner(
183
+ [snapshot, snapshot, snapshot],
184
+ [checks, checks, checks],
185
+ ),
186
+ clock=clock.monotonic,
187
+ sleeper=clock.sleep,
188
+ progress_stream=io.StringIO(),
189
+ configured_required_names={"test", "lint"},
190
+ )
191
+
192
+ self.assertIn("wait budget exceeded", stopped.exception.reason)
193
+ self.assertIn("elapsed=10.0s", stopped.exception.detail)
194
+ self.assertIn("test", stopped.exception.detail)
195
+ self.assertIn("lint", stopped.exception.detail)
196
+
197
+ def test_already_merged_pr_bypasses_check_wait(self):
198
+ snapshot = pr_snapshot(
199
+ state="MERGED", mergeable="UNKNOWN", mergeStateStatus="UNKNOWN"
200
+ )
201
+ clock = FakeClock()
202
+ commands = []
203
+
204
+ def runner(cmd, **_kwargs):
205
+ commands.append(cmd)
206
+ return completed(snapshot)
207
+
208
+ already_merged = self.wrapup.wait_for_merge_gate(
209
+ "42",
210
+ command_runner=runner,
211
+ clock=clock.monotonic,
212
+ sleeper=clock.sleep,
213
+ progress_stream=io.StringIO(),
214
+ configured_required_names={"test"},
215
+ )
216
+
217
+ self.assertTrue(already_merged)
218
+ self.assertEqual(clock.now, 0)
219
+ self.assertEqual(len(commands), 1)
220
+
221
+ def test_optional_red_check_does_not_block_when_required_check_is_green(self):
222
+ snapshot = pr_snapshot(
223
+ mergeStateStatus="BLOCKED",
224
+ statusCheckRollup=[{
225
+ "name": "advisory-browser",
226
+ "status": "COMPLETED",
227
+ "conclusion": "FAILURE",
228
+ }],
229
+ )
230
+ runner = gate_runner([snapshot], [[{
231
+ "name": "test",
232
+ "state": "SUCCESS",
233
+ "link": "https://example.test",
234
+ }]])
235
+
236
+ self.assertFalse(self.wrapup.wait_for_merge_gate(
237
+ "42", command_runner=runner, configured_required_names={"test"}
238
+ ))
239
+
240
+ def test_fresh_pr_waits_until_required_checks_become_visible(self):
241
+ snapshots = [
242
+ pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
243
+ pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
244
+ pr_snapshot(),
245
+ ]
246
+ required = [
247
+ [],
248
+ [{"name": "test", "state": "PENDING"}],
249
+ [{"name": "test", "state": "SUCCESS"}],
250
+ ]
251
+ clock = FakeClock()
252
+ progress = io.StringIO()
253
+
254
+ self.assertFalse(self.wrapup.wait_for_merge_gate(
255
+ "42",
256
+ timeout_seconds=30,
257
+ poll_interval=5,
258
+ command_runner=gate_runner(snapshots, required),
259
+ clock=clock.monotonic,
260
+ sleeper=clock.sleep,
261
+ progress_stream=progress,
262
+ configured_required_names={"test"},
263
+ ))
264
+ self.assertIn("test (awaiting discovery)", progress.getvalue())
265
+ self.assertIn("test", progress.getvalue())
266
+
267
+ def test_visible_optional_check_cannot_mask_required_discovery(self):
268
+ snapshot = pr_snapshot(
269
+ mergeStateStatus="BLOCKED",
270
+ statusCheckRollup=[{
271
+ "name": "advisory-browser",
272
+ "status": "COMPLETED",
273
+ "conclusion": "FAILURE",
274
+ }],
275
+ )
276
+
277
+ clock = FakeClock()
278
+ with self.assertRaises(self.wrapup.Stop) as stopped:
279
+ self.wrapup.wait_for_merge_gate(
280
+ "42",
281
+ timeout_seconds=0,
282
+ command_runner=gate_runner([snapshot], [[]]),
283
+ clock=clock.monotonic,
284
+ sleeper=clock.sleep,
285
+ progress_stream=io.StringIO(),
286
+ configured_required_names={"test"},
287
+ )
288
+ self.assertIn("test (awaiting discovery)", stopped.exception.detail)
289
+
290
+ def test_zero_step_failed_job_is_named_as_infrastructure_failure(self):
291
+ snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
292
+ check = {
293
+ "name": "test",
294
+ "state": "FAILURE",
295
+ "link": "https://github.com/acme/repo/actions/runs/123/job/456",
296
+ }
297
+ commands = []
298
+
299
+ def runner(cmd, **_kwargs):
300
+ commands.append(cmd)
301
+ if cmd[:3] == ["gh", "pr", "view"]:
302
+ return completed(snapshot)
303
+ if cmd[:3] == ["gh", "pr", "checks"]:
304
+ return completed([check], returncode=1)
305
+ if "--json" in cmd:
306
+ return completed({
307
+ "jobs": [{
308
+ "databaseId": 456,
309
+ "name": "test",
310
+ "conclusion": "failure",
311
+ "steps": [],
312
+ }],
313
+ })
314
+ return subprocess.CompletedProcess(
315
+ cmd, 1, "", "log unavailable"
316
+ )
317
+
318
+ with self.assertRaises(self.wrapup.Stop) as stopped:
319
+ self.wrapup.wait_for_merge_gate(
320
+ "42", command_runner=runner, configured_required_names={"test"}
321
+ )
322
+
323
+ self.assertIn("test", stopped.exception.detail)
324
+ self.assertIn("infrastructure failure", stopped.exception.detail)
325
+ self.assertIn(["gh", "run", "view", "123", "--json", "jobs"], commands)
326
+ self.assertEqual(
327
+ [cmd[:3] for cmd in commands].count(["gh", "run", "view"]), 1
328
+ )
329
+
330
+ def test_billing_annotation_is_sanitized_and_bounded(self):
331
+ noisy = (
332
+ "The job was not started because recent account payments have failed. "
333
+ + "TOKEN=secret "
334
+ + "x" * 1000
335
+ )
336
+ check = {
337
+ "name": "test",
338
+ "state": "FAILURE",
339
+ "link": "https://github.com/acme/repo/actions/runs/123/job/456",
340
+ }
341
+
342
+ def runner(cmd, **_kwargs):
343
+ if "--json" in cmd:
344
+ return completed({"jobs": [{
345
+ "databaseId": 456,
346
+ "name": "test",
347
+ "conclusion": "failure",
348
+ "steps": [{"name": "x"}],
349
+ }]})
350
+ self.assertIn("--job", cmd)
351
+ self.assertIn("456", cmd)
352
+ return subprocess.CompletedProcess(cmd, 0, noisy, "")
353
+
354
+ diagnosis = self.wrapup.infrastructure_failure_diagnosis(
355
+ check, command_runner=runner
356
+ )
357
+
358
+ self.assertIn("infrastructure failure", diagnosis)
359
+ self.assertLessEqual(len(diagnosis), self.wrapup.MAX_EXTERNAL_DETAIL)
360
+ self.assertNotIn("\n", diagnosis)
361
+
362
+ def test_mixed_run_does_not_misclassify_real_test_failure_as_infra(self):
363
+ check = {
364
+ "name": "test",
365
+ "state": "FAILURE",
366
+ "link": "https://github.com/acme/repo/actions/runs/123/job/456",
367
+ }
368
+ commands = []
369
+
370
+ def runner(cmd, **_kwargs):
371
+ commands.append(cmd)
372
+ if "--json" in cmd:
373
+ return completed({"jobs": [
374
+ {
375
+ "databaseId": 111,
376
+ "name": "setup",
377
+ "conclusion": "startup_failure",
378
+ "steps": [],
379
+ },
380
+ {
381
+ "databaseId": 456,
382
+ "name": "test",
383
+ "conclusion": "failure",
384
+ "steps": [{"name": "Run tests", "conclusion": "failure"}],
385
+ },
386
+ ]})
387
+ return subprocess.CompletedProcess(
388
+ cmd, 0, "AssertionError: expected green", ""
389
+ )
390
+
391
+ diagnosis = self.wrapup.infrastructure_failure_diagnosis(
392
+ check, command_runner=runner
393
+ )
394
+
395
+ self.assertEqual(diagnosis, "")
396
+ self.assertIn(
397
+ ["gh", "run", "view", "123", "--job", "456", "--log-failed"],
398
+ commands,
399
+ )
400
+
401
+ def test_check_without_unique_job_match_is_not_classified_from_run_logs(self):
402
+ check = {
403
+ "name": "test",
404
+ "state": "FAILURE",
405
+ "link": "https://github.com/acme/repo/actions/runs/123",
406
+ }
407
+ commands = []
408
+
409
+ def runner(cmd, **_kwargs):
410
+ commands.append(cmd)
411
+ if "--json" in cmd:
412
+ return completed({"jobs": [
413
+ {"name": "test", "conclusion": "failure", "steps": []},
414
+ {"name": "test", "conclusion": "failure", "steps": []},
415
+ ]})
416
+ raise AssertionError("aggregate run logs must not be inspected")
417
+
418
+ self.assertEqual(
419
+ self.wrapup.infrastructure_failure_diagnosis(
420
+ check, command_runner=runner
421
+ ),
422
+ "",
423
+ )
424
+ self.assertEqual(len(commands), 1)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ unittest.main()
@@ -261,11 +261,47 @@ def convention_freshness_decision(profile: dict, root: Path) -> Decision:
261
261
  )
262
262
 
263
263
 
264
- def migration_reminder_decision(profile: dict, payload: dict) -> Decision:
264
+ def _migration_session_marker(profile: dict, payload: dict, root: Path) -> Path | None:
265
+ session_id = payload.get("session_id")
266
+ if not isinstance(session_id, str) or not session_id:
267
+ return None
268
+ state_dir = root / profile.get("baseline", {}).get(
269
+ "stateDir", ".claude/logs/advisory-state",
270
+ )
271
+ safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
272
+ return state_dir / f"{safe_session}.migration.hinted"
273
+
274
+
275
+ def _unquoted_shell_text(command: str) -> str:
276
+ visible: list[str] = []
277
+ quote: str | None = None
278
+ escaped = False
279
+ for character in command:
280
+ if escaped:
281
+ visible.append(" " if quote else character)
282
+ escaped = False
283
+ elif character == "\\" and quote != "'":
284
+ visible.append(" ")
285
+ escaped = True
286
+ elif quote:
287
+ visible.append(" ")
288
+ if character == quote:
289
+ quote = None
290
+ elif character in {"'", '"', "`"}:
291
+ visible.append(" ")
292
+ quote = character
293
+ else:
294
+ visible.append(character)
295
+ return "".join(visible)
296
+
297
+
298
+ def migration_reminder_decision(
299
+ profile: dict, payload: dict, root: Path,
300
+ ) -> Decision:
265
301
  config = profile.get("migration", {})
266
302
  if payload.get("tool_name") != "Bash":
267
303
  return Decision(None, "PostToolUse")
268
- command = payload.get("tool_input", {}).get("command", "")
304
+ command = _unquoted_shell_text(payload.get("tool_input", {}).get("command", ""))
269
305
  if not any(
270
306
  re.search(pattern, command)
271
307
  for pattern in config.get("commandMatchers", [])
@@ -275,6 +311,12 @@ def migration_reminder_decision(profile: dict, payload: dict) -> Decision:
275
311
  refresh = config.get("refreshCommand", [])
276
312
  if not artifact or not refresh:
277
313
  return Decision(None, "PostToolUse")
314
+ marker = _migration_session_marker(profile, payload, root)
315
+ if marker and marker.exists():
316
+ return Decision(None, "PostToolUse")
317
+ if marker:
318
+ marker.parent.mkdir(parents=True, exist_ok=True)
319
+ marker.write_text(f"{payload['session_id']}\n", encoding="utf-8")
278
320
  message = (
279
321
  f"Migration advisory: refresh {artifact} with `{' '.join(refresh)}` "
280
322
  "before treating the migration result as complete."
@@ -17,6 +17,17 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
17
17
  - `setupEntry` and ordered `setupSteps`: the portable setup command and project
18
18
  setup sequence.
19
19
  - `riskyCommandPatterns`: commands that must target the active linked worktree.
20
+ - `scratchPatterns`: consumer-owned glob patterns for untracked disposable
21
+ planning artefacts. No filename is assumed by Core.
22
+ - `wrapup.landingGeneratedArtifactPatterns`: an explicitly reviewed
23
+ consumer-owned allowlist for outputs created by the landing commands. It is
24
+ deletion authority, so setup never derives it from `.gitignore` alone and
25
+ update never installs a universal default.
26
+
27
+ Profile globs use repository-relative POSIX semantics. `*` stays inside one
28
+ path segment; `**` crosses `/`, and leading `**/` also matches the repository
29
+ root. For example, `**/__pycache__/**` matches both root and nested caches,
30
+ while `dist-kit/*` does not match `dist-kit/a/b`.
20
31
 
21
32
  Unknown or malformed events fail open without changing repository state.
22
33
  Security-sensitive, profile-matched edits and commands fail closed only when
@@ -35,10 +46,121 @@ the core proves the target is unsafe.
35
46
 
36
47
  ## Cleanup
37
48
 
38
- `cleanup.py` previews by default. Removal refuses protected, dirty, or unmerged
39
- worktrees. The assessment reads `ANNAHMEN.md` before removal and returns its
40
- contents for propagation. `wrapup-land.py` invokes this same assessment after a
41
- merge and before killing processes or removing the worktree.
49
+ `cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
50
+ non-scratch-dirty, open-PR, or unmerged worktrees. Profile-declared untracked
51
+ scratch is named in the report but does not block removal. The assessment reads
52
+ `ANNAHMEN.md` before removal and returns its contents for propagation.
53
+ `wrapup-land.py` invokes this same assessment after a merge and before killing
54
+ processes or removing the worktree.
55
+
56
+ Explicit removal re-collects facts immediately before mutation, requires the
57
+ same removable inventory, deletes only the exact contained regular scratch
58
+ files from that inventory, and uses ordinary `git worktree remove`. It never
59
+ bypasses Git's final concurrent-change check with force removal.
60
+
61
+ The generic setup route atomically records its ignored and complete
62
+ untracked-file inventories in the linked worktree's Git metadata after its
63
+ configured setup steps. The record is bound to the worktree path, branch, root
64
+ device/inode, and setup HEAD, and carries a canonical digest. Existing
65
+ worktrees can receive a conservative baseline only when they are the exact
66
+ registered no-follow directory on an attached branch with a clean tracked
67
+ worktree and index. Setup reuse defers that backfill, without blocking the
68
+ existing worktree, while tracked work is dirty or landing-generated blockers
69
+ are present. Landing classifies those blockers before writing any baseline;
70
+ after they are moved, every remaining current ignored and untracked path is
71
+ recorded as pre-existing and therefore protected. A corrupt baseline or an
72
+ active landing attempt is never overwritten. The claim-bound session route
73
+ below captures its stricter baseline before project setup so a failed setup has
74
+ an exact recovery boundary.
75
+
76
+ Before merge, the committed worktree profile may only nominate exact,
77
+ identity-bound landing candidates; it authorizes no deletion. After merge and
78
+ `fetch origin/main`, cleanup reloads the profile directly from canonical
79
+ `origin/main`, requires its scratch and generator policies to equal the
80
+ worktree candidate and the policy digest frozen at attempt start, and only then
81
+ authorizes mutation. Every supplied generator-evidence path is independently
82
+ checked against that canonical generator policy. A missing policy is distinct
83
+ from an explicit empty policy. An unmerged or transient branch policy therefore
84
+ cannot grant itself broader cleanup authority.
85
+
86
+ The landing adapter may carry exact scratch evidence only for current ignored
87
+ files that match the consumer-owned
88
+ `wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
89
+ creation baseline. Missing, changed, or incoherent provenance stops landing
90
+ cleanup. Initial/profile-matched files, unmatched files, symlinks, and writes
91
+ after the landing evidence snapshot remain cleanup stops; deletion still uses
92
+ the same descriptor-bound regular-file primitive and a second inventory check.
93
+ Mutable session logs belong in explicit `scratchPatterns`, not in the landing
94
+ generator allowlist: their identity is frozen at final cleanup assessment, so
95
+ normal logging before teardown remains live while a later append or replacement
96
+ still stops deletion.
97
+ Landing-start blockers are classified before a journal is written, so moving a
98
+ protected blocker permits a clean next attempt. An explicit relinquish archives
99
+ either a started or frozen attempt, including drifted evidence, without
100
+ deleting or claiming any file; the next preflight treats every current file as
101
+ pre-existing. Exact unchanged frozen evidence remains directly resumable.
102
+
103
+ `cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
104
+ every linked worktree and local branch, reports issue/PR/merge/age/removal
105
+ facts, and counts merged remote branches separately. It never removes a
106
+ worktree or branch.
107
+
108
+ ## Session-owned teardown
109
+
110
+ `session.py` is the narrower orchestration route. It binds one receipt in the
111
+ Git common directory to the active `wave-active/<anchor>` annotated claim:
112
+
113
+ ```sh
114
+ python3 scripts/worktree-lifecycle/session.py begin --anchor <n> --owner <run> --base <wave-head>
115
+ python3 scripts/worktree-lifecycle/session.py create --anchor <n> --owner <run> --base <current-wave-head> <issue> <slug> <type>
116
+ python3 scripts/worktree-lifecycle/session.py recover --anchor <n> --owner <run> --branch <exact-branch>
117
+ python3 scripts/worktree-lifecycle/session.py seal --anchor <n> --owner <run>
118
+ python3 scripts/worktree-lifecycle/session.py inspect --anchor <n> --owner <run> --main origin/main
119
+ python3 scripts/worktree-lifecycle/session.py teardown --anchor <n> --owner <run> --main origin/main
120
+ ```
121
+
122
+ Only `create` can add an inventory row, and it refuses any branch or path that
123
+ already existed. It journals a provisional exact row before `git worktree add`,
124
+ then captures the shared artifact baseline before project setup and promotes
125
+ the row only after setup succeeds. Session inspection
126
+ therefore accepts generated scratch only when the exact path matches the
127
+ profile and is absent from the creation baseline; missing, changed, or
128
+ incoherent provenance stops. `seal` records the final exact branch OIDs.
129
+ Inspection reports ancestry, one-to-one stable patch equivalence, unique
130
+ content, and ambiguity separately. Empty commits, non-ancestry merge commits,
131
+ duplicate patch IDs, open or unreadable PR evidence, dirt, protected names,
132
+ stale registrations, recreated removed targets, and identity drift stop the
133
+ whole teardown before mutation.
134
+
135
+ If setup fails, the provisional row becomes `recovery-pending`. Automatic and
136
+ explicit `recover` use the same bounded route: active claim and receipt,
137
+ protected name, PR state, exact ref OID, registration/root identity, and the
138
+ creation baseline are revalidated. A branch-only remainder is compare-deleted
139
+ only at its provisional OID. A registered worktree whose add succeeded before
140
+ root journaling is adopted only when its Git registration/backlink, branch,
141
+ OID, and exact-clean inventory all match.
142
+
143
+ For failed project setup, exact index/worktree diff hashes and paths are frozen
144
+ without storing diff content. Exact regular-file or no-follow symlink identity
145
+ is frozen for every setup-created untracked path. Recovery revalidates that
146
+ evidence, restores only the frozen tracked paths to the creation OID, and
147
+ unlinks only matching untracked identities; later foreign files,
148
+ modifications, or replacements stop without losing receipt ownership. If
149
+ identity capture cannot finish, the receipt retains only a bounded failure
150
+ class and can retry from the unchanged inventory or an exact-clean worktree.
151
+ Raw command, exception, stdout, and stderr text is never retained or archived.
152
+ Recovery uses ordinary worktree removal and compare-deletes the exact ref.
153
+
154
+ Teardown re-runs that complete assessment, archives every recovery OID in the
155
+ receipt before its first mutation, and revalidates canonical main, the active
156
+ claim and receipt, profile protection, PR state, branch OID, worktree
157
+ registration/root identity, dirt, and the exact scratch inventory immediately
158
+ before each target mutation. After ordinary worktree removal it rechecks the
159
+ ref-side gates, then compare-deletes only the recorded local refs with
160
+ `git update-ref -d <ref> <expected-oid>`. A concurrent ref move therefore
161
+ survives, while a partial run keeps its recovery OIDs and can resume. Generic
162
+ cleanup and sweep never consume this ownership route and never infer authority
163
+ over a foreign branch.
42
164
 
43
165
  Claude hook wiring and any Codex adaptation consume this same profile and core.
44
166
  An adapter may change only the surface event envelope; it must preserve the
@@ -39,7 +39,15 @@
39
39
  {
40
40
  "historicalPath": "scripts/cleanup-worktrees.sh",
41
41
  "artifact": "scripts/worktree-lifecycle/cleanup.py",
42
- "primitives": ["profile", "git-facts", "cleanup-safety"]
42
+ "supportingArtifacts": ["scripts/worktree-lifecycle/session.py"],
43
+ "primitives": [
44
+ "profile",
45
+ "git-facts",
46
+ "cleanup-safety",
47
+ "claim-bound-session-receipt",
48
+ "patch-equivalence",
49
+ "compare-and-delete"
50
+ ]
43
51
  }
44
52
  ]
45
53
  }