@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,1857 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Claim-bound creation and teardown of one orchestration run's exact worktrees."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- from collections import Counter
8
- from contextlib import contextmanager
9
- import fcntl
10
- from hashlib import sha256
11
- import json
12
- import os
13
- from pathlib import Path
14
- import stat
15
- import subprocess
16
- import sys
17
- from typing import Any
18
-
19
- from cleanup import pr_state
20
- from core import (
21
- LifecycleError,
22
- capture_artifact_baseline,
23
- bind_cleanup_scratch_evidence,
24
- classify_cleanup,
25
- collect_cleanup_facts,
26
- contained_untracked_identity,
27
- durable_atomic_json,
28
- load_profile,
29
- load_artifact_baseline,
30
- main_worktree,
31
- registered_worktrees,
32
- remove_authorized_scratch,
33
- remove_contained_untracked,
34
- run,
35
- verified_landing_scratch_evidence,
36
- verified_worktree_root,
37
- )
38
- from setup import execute_step
39
-
40
- SCHEMA_VERSION = 1
41
-
42
-
43
- def resolve_commit(repo: Path, rev: str) -> str | None:
44
- result = run(
45
- ["git", "rev-parse", "--verify", f"{rev}^{{commit}}"],
46
- cwd=repo,
47
- check=False,
48
- )
49
- return result.stdout.strip() if result.returncode == 0 else None
50
-
51
-
52
- def resolve_ref(repo: Path, ref: str) -> str | None:
53
- result = run(["git", "rev-parse", "--verify", ref], cwd=repo, check=False)
54
- return result.stdout.strip() if result.returncode == 0 else None
55
-
56
-
57
- def active_claim(main: Path, anchor: str, owner: str) -> tuple[str, dict[str, Any]]:
58
- if not anchor.isdigit() or int(anchor) <= 0:
59
- raise LifecycleError("anchor must be a positive issue number")
60
- if not owner.strip():
61
- raise LifecycleError("owner must be non-empty")
62
- ref = f"refs/tags/wave-active/{anchor}"
63
- tag_oid = resolve_ref(main, ref)
64
- if tag_oid is None:
65
- raise LifecycleError(f"active wave claim is missing: {ref}")
66
- kind = run(["git", "cat-file", "-t", tag_oid], cwd=main).stdout.strip()
67
- if kind != "tag":
68
- raise LifecycleError(f"active wave claim is not annotated: {ref}")
69
- raw = run(["git", "cat-file", "-p", tag_oid], cwd=main).stdout
70
- separator = raw.find("\n\n")
71
- try:
72
- claim = json.loads(raw[separator + 2:].strip()) if separator >= 0 else None
73
- except json.JSONDecodeError as error:
74
- raise LifecycleError("active wave claim has invalid ownership evidence") from error
75
- if (
76
- not isinstance(claim, dict)
77
- or claim.get("contractVersion") != 1
78
- or str(claim.get("anchor")) != anchor
79
- or claim.get("owner") != owner
80
- ):
81
- raise LifecycleError("active wave claim belongs to another or incoherent run")
82
- return tag_oid, claim
83
-
84
-
85
- def common_git_dir(main: Path) -> Path:
86
- value = run(
87
- ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
88
- cwd=main,
89
- ).stdout.strip()
90
- return Path(value).resolve()
91
-
92
-
93
- def receipt_path(main: Path, anchor: str, claim_oid: str) -> Path:
94
- return common_git_dir(main) / "agent-workflow-kit" / "session-teardown" / (
95
- f"{anchor}-{claim_oid}.json"
96
- )
97
-
98
-
99
- def ownership_proof_ref(claim_oid: str, branch: str) -> str:
100
- branch_digest = sha256(branch.encode("utf-8")).hexdigest()
101
- return (
102
- "refs/agent-workflow-kit/session-owned/"
103
- f"{claim_oid}/{branch_digest}"
104
- )
105
-
106
-
107
- def validated_proof_ref(receipt: dict[str, Any], entry: dict[str, Any]) -> str:
108
- expected = ownership_proof_ref(receipt["claimOid"], entry["branch"])
109
- if entry.get("proofRef") != expected:
110
- raise LifecycleError("session ownership proof identity is incoherent")
111
- return expected
112
-
113
-
114
- def acquire_owned_branch(
115
- main: Path,
116
- branch: str,
117
- proof_ref: str,
118
- target_oid: str,
119
- claim_ref: str,
120
- claim_oid: str,
121
- ) -> bool:
122
- transaction = (
123
- "start\n"
124
- f"verify {claim_ref} {claim_oid}\n"
125
- f"create refs/heads/{branch} {target_oid}\n"
126
- f"create {proof_ref} {target_oid}\n"
127
- "prepare\n"
128
- "commit\n"
129
- )
130
- result = subprocess.run(
131
- ["git", "update-ref", "--stdin"],
132
- cwd=main,
133
- input=transaction,
134
- capture_output=True,
135
- text=True,
136
- check=False,
137
- )
138
- return result.returncode == 0
139
-
140
-
141
- def delete_owned_refs_prepared(
142
- main: Path,
143
- branch: str,
144
- proof_ref: str,
145
- expected_oid: str,
146
- proof_oid: str,
147
- claim_ref: str,
148
- claim_oid: str,
149
- *,
150
- remove_worktree: Path | None = None,
151
- main_ref: str | None = None,
152
- main_oid: str | None = None,
153
- root_identity: tuple[int, int] | None = None,
154
- ) -> bool:
155
- process = subprocess.Popen(
156
- ["git", "update-ref", "--stdin"],
157
- cwd=main,
158
- stdin=subprocess.PIPE,
159
- stdout=subprocess.PIPE,
160
- stderr=subprocess.PIPE,
161
- text=True,
162
- )
163
- assert process.stdin is not None
164
- assert process.stdout is not None
165
- commands = [
166
- "start",
167
- f"verify {claim_ref} {claim_oid}",
168
- ]
169
- if main_ref is not None and main_oid is not None:
170
- commands.append(f"verify {main_ref} {main_oid}")
171
- commands.extend([
172
- f"delete refs/heads/{branch} {expected_oid}",
173
- f"delete {proof_ref} {proof_oid}",
174
- "prepare",
175
- ])
176
- try:
177
- process.stdin.write("\n".join(commands) + "\n")
178
- process.stdin.flush()
179
- responses = [process.stdout.readline().strip(), process.stdout.readline().strip()]
180
- if responses != ["start: ok", "prepare: ok"]:
181
- process.stdin.close()
182
- process.wait()
183
- return False
184
- linked = worktree_branches(main)
185
- if (
186
- (remove_worktree is None and branch in linked)
187
- or (
188
- remove_worktree is not None
189
- and linked.get(branch) != remove_worktree.resolve()
190
- )
191
- ):
192
- process.stdin.write("abort\n")
193
- process.stdin.flush()
194
- process.stdin.close()
195
- process.wait()
196
- return False
197
- if remove_worktree is not None:
198
- try:
199
- metadata = remove_worktree.lstat()
200
- except OSError:
201
- metadata = None
202
- if (
203
- metadata is None
204
- or not stat.S_ISDIR(metadata.st_mode)
205
- or root_identity is None
206
- or (metadata.st_dev, metadata.st_ino) != root_identity
207
- ):
208
- process.stdin.write("abort\n")
209
- process.stdin.flush()
210
- process.stdin.close()
211
- process.wait()
212
- return False
213
- removed = run(
214
- ["git", "worktree", "remove", str(remove_worktree)],
215
- cwd=main,
216
- check=False,
217
- )
218
- if removed.returncode != 0:
219
- process.stdin.write("abort\n")
220
- process.stdin.flush()
221
- process.stdin.close()
222
- process.wait()
223
- return False
224
- process.stdin.write("commit\n")
225
- process.stdin.flush()
226
- process.stdin.close()
227
- return process.wait() == 0
228
- except (BrokenPipeError, OSError):
229
- if process.poll() is None:
230
- process.kill()
231
- process.wait()
232
- return False
233
-
234
-
235
- @contextmanager
236
- def receipt_lock(main: Path, anchor: str):
237
- directory = common_git_dir(main) / "agent-workflow-kit" / "session-teardown"
238
- directory.mkdir(parents=True, exist_ok=True)
239
- lock_path = directory / f"{anchor}.lock"
240
- descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
241
- try:
242
- fcntl.flock(descriptor, fcntl.LOCK_EX)
243
- yield
244
- finally:
245
- fcntl.flock(descriptor, fcntl.LOCK_UN)
246
- os.close(descriptor)
247
-
248
-
249
- def write_receipt(path: Path, receipt: dict[str, Any]) -> None:
250
- path.parent.mkdir(parents=True, exist_ok=True)
251
- durable_atomic_json(
252
- path,
253
- receipt,
254
- label="write session teardown receipt",
255
- mode=0o600,
256
- sort_keys=True,
257
- )
258
-
259
-
260
- def read_receipt(
261
- main: Path,
262
- anchor: str,
263
- owner: str,
264
- ) -> tuple[Path, dict[str, Any]]:
265
- claim_oid, _ = active_claim(main, anchor, owner)
266
- path = receipt_path(main, anchor, claim_oid)
267
- try:
268
- receipt = json.loads(path.read_text(encoding="utf-8"))
269
- except (OSError, json.JSONDecodeError) as error:
270
- raise LifecycleError(f"cannot read this run's teardown receipt: {error}") from error
271
- if (
272
- not isinstance(receipt, dict)
273
- or receipt.get("schemaVersion") != SCHEMA_VERSION
274
- or receipt.get("anchor") != anchor
275
- or receipt.get("owner") != owner
276
- or receipt.get("claimOid") != claim_oid
277
- or Path(str(receipt.get("repoRoot", ""))).resolve() != main.resolve()
278
- or not isinstance(receipt.get("targets"), list)
279
- ):
280
- raise LifecycleError("teardown receipt belongs to another or incoherent run")
281
- return path, receipt
282
-
283
-
284
- def begin(main: Path, args: argparse.Namespace) -> dict[str, Any]:
285
- claim_oid, _ = active_claim(main, args.anchor, args.owner)
286
- base_oid = resolve_commit(main, args.base)
287
- if base_oid is None:
288
- raise LifecycleError(f"base is not resolvable: {args.base}")
289
- path = receipt_path(main, args.anchor, claim_oid)
290
- with receipt_lock(main, args.anchor):
291
- if path.exists():
292
- _, receipt = read_receipt(main, args.anchor, args.owner)
293
- if receipt.get("baseOid") != base_oid:
294
- raise LifecycleError("existing receipt has a different base OID")
295
- return receipt_report(path, receipt)
296
- receipt = {
297
- "schemaVersion": SCHEMA_VERSION,
298
- "anchor": args.anchor,
299
- "owner": args.owner,
300
- "claimOid": claim_oid,
301
- "repoRoot": str(main.resolve()),
302
- "baseRef": args.base,
303
- "baseOid": base_oid,
304
- "state": "open",
305
- "targets": [],
306
- }
307
- write_receipt(path, receipt)
308
- return receipt_report(path, receipt)
309
-
310
-
311
- def profile_path(main: Path, value: str) -> Path:
312
- path = Path(value)
313
- return path.resolve() if path.is_absolute() else (main / path).resolve()
314
-
315
-
316
- def contained_target(main: Path, relative: Path) -> Path:
317
- if relative.is_absolute() or not relative.parts or any(
318
- part in {"", ".", ".."} for part in relative.parts
319
- ):
320
- raise LifecycleError(f"worktree path escapes repository root: {relative}")
321
- return main.resolve().joinpath(*relative.parts)
322
-
323
-
324
- def create_target(main: Path, args: argparse.Namespace) -> dict[str, Any]:
325
- with receipt_lock(main, args.anchor):
326
- path, receipt = read_receipt(main, args.anchor, args.owner)
327
- if receipt.get("state") != "open":
328
- raise LifecycleError("teardown receipt is already sealed")
329
- configured_path = profile_path(main, args.profile)
330
- profile = load_profile(configured_path)
331
- branch = profile.branch_name(args.issue, args.slug, args.branch_type)
332
- if run(
333
- ["git", "check-ref-format", "--branch", branch],
334
- cwd=main,
335
- check=False,
336
- ).returncode != 0:
337
- raise LifecycleError("generated session branch is not a valid branch name")
338
- target = contained_target(
339
- main,
340
- profile.relative_path(args.issue, args.slug, args.branch_type),
341
- )
342
- if branch in profile.protected_branches:
343
- raise LifecycleError(f"refusing protected session branch: {branch}")
344
- if any(entry.get("branch") == branch or entry.get("worktree") == str(target)
345
- for entry in receipt["targets"]):
346
- raise LifecycleError("target is already present in this receipt")
347
- if resolve_ref(main, f"refs/heads/{branch}") is not None:
348
- raise LifecycleError(f"branch pre-existed this run: {branch}")
349
- if os.path.lexists(target) or target in registered_worktrees(main):
350
- raise LifecycleError(f"worktree path pre-existed this run: {target}")
351
- target_base = (
352
- resolve_commit(main, args.base) if args.base else receipt["baseOid"]
353
- )
354
- if target_base is None or not is_ancestor(main, receipt["baseOid"], target_base):
355
- raise LifecycleError("target base is unresolved or incoherent with the receipt base")
356
- proof_ref = ownership_proof_ref(receipt["claimOid"], branch)
357
- if resolve_ref(main, proof_ref) is not None:
358
- raise LifecycleError("session ownership proof pre-existed this run")
359
-
360
- entry = {
361
- "state": "provisional",
362
- "acquisitionState": "pending",
363
- "branch": branch,
364
- "worktree": str(target),
365
- "profile": str(configured_path),
366
- "proofRef": proof_ref,
367
- "createdOid": target_base,
368
- "expectedOid": target_base,
369
- "rootDevice": None,
370
- "rootInode": None,
371
- "artifactBaselineDigest": None,
372
- "removed": False,
373
- }
374
- # Pre-journal the deterministic proof identity, then acquire the branch
375
- # and proof ref in one Git ref transaction. The proof, rather than this
376
- # provisional row alone, is the durable ownership authority.
377
- receipt["targets"].append(entry)
378
- write_receipt(path, receipt)
379
- failure_class = "ref-acquisition"
380
- try:
381
- if not acquire_owned_branch(
382
- main,
383
- branch,
384
- proof_ref,
385
- target_base,
386
- f"refs/tags/wave-active/{args.anchor}",
387
- receipt["claimOid"],
388
- ):
389
- entry["acquisitionState"] = "failed"
390
- write_receipt(path, receipt)
391
- raise LifecycleError("session branch ownership acquisition failed")
392
- entry["acquisitionState"] = "acquired"
393
- write_receipt(path, receipt)
394
- failure_class = "root-preparation"
395
- parent, prepared = prepare_target_root_nofollow(main, target)
396
- entry.update({
397
- "state": "worktree-pending",
398
- "parentDevice": parent.st_dev,
399
- "parentInode": parent.st_ino,
400
- "rootDevice": prepared.st_dev,
401
- "rootInode": prepared.st_ino,
402
- })
403
- write_receipt(path, receipt)
404
- failure_class = "worktree-add"
405
- verify_target_parent_nofollow(
406
- main,
407
- target,
408
- (entry["parentDevice"], entry["parentInode"]),
409
- )
410
- run(
411
- ["git", "worktree", "add", str(target), branch],
412
- cwd=main,
413
- )
414
- failure_class = "root-journal"
415
- created_oid = resolve_ref(main, f"refs/heads/{branch}")
416
- if created_oid != target_base:
417
- raise LifecycleError("new session branch did not retain the recorded base OID")
418
- metadata = verify_created_worktree_root(
419
- main,
420
- target,
421
- branch,
422
- expected_parent_identity=(
423
- entry["parentDevice"], entry["parentInode"]
424
- ),
425
- )
426
- if (metadata.st_dev, metadata.st_ino) != (
427
- entry["rootDevice"], entry["rootInode"]
428
- ):
429
- raise LifecycleError("new session worktree root identity changed")
430
- entry.update({
431
- "state": "baseline-pending",
432
- "rootDevice": metadata.st_dev,
433
- "rootInode": metadata.st_ino,
434
- })
435
- write_receipt(path, receipt)
436
-
437
- # This baseline must precede every project setup step: only its
438
- # exact untracked delta can later be attributed to failed setup.
439
- failure_class = "baseline-capture"
440
- verify_created_worktree_root(
441
- main,
442
- target,
443
- branch,
444
- expected_identity=(entry["rootDevice"], entry["rootInode"]),
445
- expected_parent_identity=(
446
- entry["parentDevice"], entry["parentInode"]
447
- ),
448
- )
449
- artifact_baseline = capture_artifact_baseline(target)
450
- if artifact_baseline.setup_head != created_oid:
451
- raise LifecycleError(
452
- "artifact provenance baseline does not match the created OID"
453
- )
454
- entry.update({
455
- "state": "setting-up",
456
- "artifactBaselineDigest": artifact_baseline.digest,
457
- })
458
- write_receipt(path, receipt)
459
- failure_class = "setup-step"
460
- for step in profile.setup_steps:
461
- verify_created_worktree_root(
462
- main,
463
- target,
464
- branch,
465
- expected_identity=(entry["rootDevice"], entry["rootInode"]),
466
- expected_parent_identity=(
467
- entry["parentDevice"], entry["parentInode"]
468
- ),
469
- )
470
- execute_step(
471
- step,
472
- main=main,
473
- worktree=target,
474
- issue=args.issue,
475
- branch=branch,
476
- )
477
- failure_class = "promotion"
478
- verify_created_worktree_root(
479
- main,
480
- target,
481
- branch,
482
- expected_identity=(entry["rootDevice"], entry["rootInode"]),
483
- expected_parent_identity=(
484
- entry["parentDevice"], entry["parentInode"]
485
- ),
486
- )
487
- created_oid = resolve_ref(main, f"refs/heads/{branch}")
488
- if created_oid != target_base:
489
- raise LifecycleError("new session branch did not retain the recorded base OID")
490
- metadata = target.lstat()
491
- if (metadata.st_dev, metadata.st_ino) != (
492
- entry["rootDevice"], entry["rootInode"]
493
- ):
494
- raise LifecycleError("session worktree root changed during setup")
495
- entry.update({
496
- "state": "active",
497
- "rootDevice": metadata.st_dev,
498
- "rootInode": metadata.st_ino,
499
- "expectedOid": None,
500
- })
501
- write_receipt(path, receipt)
502
- except Exception as error:
503
- entry["state"] = "recovery-pending"
504
- entry.pop("failure", None)
505
- entry["failureClass"] = failure_class
506
- write_receipt(path, receipt)
507
- try:
508
- _capture_setup_created_evidence(main, target, entry)
509
- except Exception:
510
- entry["evidenceState"] = "pending"
511
- entry["evidenceFailureClass"] = "identity-capture"
512
- write_receipt(path, receipt)
513
- try:
514
- _recover_creation_entry(
515
- main,
516
- args,
517
- path,
518
- receipt,
519
- entry,
520
- archive_reason="automatic rollback after failed setup",
521
- )
522
- except Exception:
523
- # Ownership and the bounded failure class are already durable.
524
- # Never surface a setup command's exception/stdout/stderr.
525
- pass
526
- raise LifecycleError(
527
- f"session creation failed ({failure_class}); recovery receipt retained"
528
- ) from error
529
- return {"branch": branch, "worktree": str(target), "createdOid": created_oid}
530
-
531
-
532
- def _capture_setup_created_evidence(
533
- main: Path,
534
- target: Path,
535
- entry: dict[str, Any],
536
- ) -> None:
537
- entry.pop("evidenceFailureClass", None)
538
- if entry.get("rootDevice") is not None:
539
- verify_created_worktree_root(
540
- main,
541
- target,
542
- entry["branch"],
543
- expected_identity=(entry["rootDevice"], entry["rootInode"]),
544
- expected_parent_identity=(
545
- entry["parentDevice"], entry["parentInode"]
546
- ),
547
- )
548
- if not target.exists() or not entry.get("artifactBaselineDigest"):
549
- entry["setupCreatedFiles"] = []
550
- entry["setupTrackedEvidence"] = _tracked_evidence(target) if target.exists() else None
551
- entry["evidenceState"] = "complete"
552
- return
553
- baseline = load_artifact_baseline(target)
554
- if baseline.digest != entry["artifactBaselineDigest"]:
555
- raise LifecycleError("creation baseline changed before recovery journaling")
556
- facts = collect_cleanup_facts(main, target, pr_state="none")
557
- created = tuple(sorted(
558
- set(facts.untracked_files).difference(baseline.initial_untracked_files)
559
- ))
560
- tracked = _tracked_evidence(target)
561
- entry["setupTrackedEvidence"] = tracked
562
- entry["setupCreatedPaths"] = list(created)
563
- entry["setupInventoryDigest"] = _inventory_digest(tracked, created)
564
- entry["evidenceState"] = "pending"
565
- with verified_worktree_root(
566
- target,
567
- entry["rootDevice"],
568
- entry["rootInode"],
569
- ) as descriptor:
570
- entry["setupCreatedFiles"] = [
571
- contained_untracked_identity(descriptor, relative)
572
- for relative in created
573
- ]
574
- entry["evidenceState"] = "complete"
575
-
576
-
577
- def _tracked_evidence(target: Path) -> dict[str, Any]:
578
- paths: set[str] = set()
579
- digests: dict[str, str] = {}
580
- for name, cached in (("worktree", False), ("index", True)):
581
- diff_command = [
582
- "git", "diff", "--binary", "--full-index", "--no-renames",
583
- ]
584
- names_command = ["git", "diff", "--name-only", "-z", "--no-renames"]
585
- if cached:
586
- diff_command.insert(2, "--cached")
587
- names_command.insert(2, "--cached")
588
- diff = run(diff_command, cwd=target).stdout
589
- names = run(names_command, cwd=target).stdout
590
- values = [value for value in names.split("\0") if value]
591
- if any(
592
- Path(value).is_absolute() or ".." in Path(value).parts
593
- for value in values
594
- ):
595
- raise LifecycleError("tracked recovery path is unsafe")
596
- paths.update(values)
597
- digests[f"{name}DiffSha256"] = sha256(
598
- diff.encode("utf-8", errors="surrogateescape")
599
- ).hexdigest()
600
- return {"paths": sorted(paths), **digests}
601
-
602
-
603
- def _inventory_digest(
604
- tracked: dict[str, Any],
605
- untracked_paths: tuple[str, ...] | list[str],
606
- ) -> str:
607
- payload = json.dumps(
608
- {"tracked": tracked, "untrackedPaths": list(untracked_paths)},
609
- ensure_ascii=False,
610
- separators=(",", ":"),
611
- sort_keys=True,
612
- ).encode("utf-8")
613
- return sha256(payload).hexdigest()
614
-
615
-
616
- def _tracked_evidence_at_created_oid() -> dict[str, Any]:
617
- empty = sha256(b"").hexdigest()
618
- return {
619
- "paths": [],
620
- "worktreeDiffSha256": empty,
621
- "indexDiffSha256": empty,
622
- }
623
-
624
-
625
- def _worktree_backlink_matches(main: Path, target: Path, branch: str) -> bool:
626
- top = run(
627
- ["git", "rev-parse", "--show-toplevel"],
628
- cwd=target,
629
- check=False,
630
- )
631
- common = run(
632
- ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
633
- cwd=target,
634
- check=False,
635
- )
636
- current = run(
637
- ["git", "branch", "--show-current"],
638
- cwd=target,
639
- check=False,
640
- )
641
- return (
642
- top.returncode == 0
643
- and Path(top.stdout.strip()).resolve() == target.resolve()
644
- and common.returncode == 0
645
- and Path(common.stdout.strip()).resolve() == common_git_dir(main)
646
- and current.returncode == 0
647
- and current.stdout.strip() == branch
648
- )
649
-
650
-
651
- def _archive_recovered_target(
652
- path: Path,
653
- receipt: dict[str, Any],
654
- entry: dict[str, Any],
655
- reason: str,
656
- ) -> None:
657
- entry.pop("failure", None)
658
- archived = {key: value for key, value in entry.items() if key != "failure"}
659
- receipt.setdefault("recoveredTargets", []).append({
660
- **archived,
661
- "state": "recovered",
662
- "recoveryReason": reason,
663
- })
664
- receipt["targets"].remove(entry)
665
- write_receipt(path, receipt)
666
-
667
-
668
- def _revalidate_creation_safety(
669
- main: Path,
670
- args: argparse.Namespace,
671
- path: Path,
672
- receipt: dict[str, Any],
673
- entry: dict[str, Any],
674
- ) -> None:
675
- branch = entry["branch"]
676
- expected_oid = entry["createdOid"]
677
- proof_ref = validated_proof_ref(receipt, entry)
678
- # The PR query is deliberately before the final receipt/claim read: a
679
- # command that races either ownership input is detected before mutation.
680
- if pr_state(args.gh_command, main, branch) == "open":
681
- raise LifecycleError(f"open PR during creation recovery: {branch}")
682
- current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
683
- if current_path != path or current_receipt != receipt:
684
- raise LifecycleError("active claim or recovery receipt changed")
685
- profile = load_profile(Path(entry["profile"]))
686
- if branch in profile.protected_branches:
687
- raise LifecycleError(f"protected branch during creation recovery: {branch}")
688
- if resolve_ref(main, f"refs/heads/{branch}") != expected_oid:
689
- raise LifecycleError(f"unexpected OID during creation recovery: {branch}")
690
- if resolve_ref(main, proof_ref) != expected_oid:
691
- raise LifecycleError(f"session ownership proof changed: {branch}")
692
-
693
-
694
- def _recover_creation_entry(
695
- main: Path,
696
- args: argparse.Namespace,
697
- path: Path,
698
- receipt: dict[str, Any],
699
- entry: dict[str, Any],
700
- *,
701
- archive_reason: str,
702
- ) -> None:
703
- current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
704
- if current_path != path or current_receipt != receipt:
705
- raise LifecycleError("active claim or recovery receipt changed")
706
- if entry.get("state") != "recovery-pending":
707
- raise LifecycleError("target is not pending creation recovery")
708
-
709
- branch = entry["branch"]
710
- target = Path(entry["worktree"])
711
- proof_ref = validated_proof_ref(receipt, entry)
712
-
713
- current_oid = resolve_ref(main, f"refs/heads/{branch}")
714
- expected_oid = entry["createdOid"]
715
- proof_oid = resolve_ref(main, proof_ref)
716
- linked = worktree_branches(main)
717
- linked_path = linked.get(branch)
718
- target_present = os.path.lexists(target)
719
- if entry.get("acquisitionState") == "failed":
720
- if proof_oid is not None:
721
- raise LifecycleError(
722
- f"failed acquisition has ambiguous ownership proof: {branch}"
723
- )
724
- # The atomic ref transaction is the sole ownership grant. Without its
725
- # proof ref, every same-name branch/path/registration is foreign and
726
- # must be ignored rather than turning the receipt into a liveness trap.
727
- _archive_recovered_target(path, receipt, entry, archive_reason)
728
- return
729
- if proof_oid is None:
730
- if target_present or linked_path is not None:
731
- raise LifecycleError(
732
- f"session ownership proof is missing while a worktree remains: {branch}"
733
- )
734
- # A failed transaction may leave a foreign branch at the intended
735
- # name. It is never adopted or deleted without the proof ref.
736
- _archive_recovered_target(path, receipt, entry, archive_reason)
737
- return
738
- if proof_oid != expected_oid:
739
- raise LifecycleError(f"session ownership proof changed: {branch}")
740
- if current_oid is not None and current_oid != expected_oid:
741
- raise LifecycleError(
742
- f"unexpected OID during creation recovery: {branch}: "
743
- f"expected {expected_oid}, found {current_oid}"
744
- )
745
- if current_oid is None and (target_present or linked_path is not None):
746
- raise LifecycleError(
747
- f"session ref disappeared while its worktree remains: {branch}"
748
- )
749
- if current_oid is None and not target_present and linked_path is None:
750
- raise LifecycleError(
751
- f"session ref disappeared while its ownership proof remains: {branch}"
752
- )
753
- if current_oid == expected_oid and not target_present and linked_path is None:
754
- _revalidate_creation_safety(main, args, path, receipt, entry)
755
- if not delete_owned_refs_prepared(
756
- main,
757
- branch,
758
- proof_ref,
759
- expected_oid,
760
- entry["createdOid"],
761
- f"refs/tags/wave-active/{args.anchor}",
762
- receipt["claimOid"],
763
- ):
764
- raise LifecycleError(
765
- f"compare-delete failed during branch-only creation recovery: {branch}"
766
- )
767
- _archive_recovered_target(path, receipt, entry, archive_reason)
768
- return
769
- if current_oid == expected_oid and target_present and linked_path is None:
770
- metadata = target.lstat()
771
- recorded_identity = (entry.get("rootDevice"), entry.get("rootInode"))
772
- if (
773
- not stat.S_ISDIR(metadata.st_mode)
774
- or None in recorded_identity
775
- or recorded_identity != (metadata.st_dev, metadata.st_ino)
776
- ):
777
- raise LifecycleError(
778
- f"unregistered prepared root identity changed: {branch}"
779
- )
780
- with verified_worktree_root(
781
- target, entry["rootDevice"], entry["rootInode"]
782
- ) as descriptor:
783
- if os.listdir(descriptor):
784
- raise LifecycleError(
785
- f"unregistered prepared root is not empty: {branch}"
786
- )
787
- _revalidate_creation_safety(main, args, path, receipt, entry)
788
- try:
789
- os.rmdir(target)
790
- except OSError as error:
791
- raise LifecycleError(
792
- f"unregistered prepared root changed before removal: {branch}"
793
- ) from error
794
- if not delete_owned_refs_prepared(
795
- main,
796
- branch,
797
- proof_ref,
798
- expected_oid,
799
- entry["createdOid"],
800
- f"refs/tags/wave-active/{args.anchor}",
801
- receipt["claimOid"],
802
- ):
803
- raise LifecycleError(
804
- f"compare-delete failed after prepared-root recovery: {branch}"
805
- )
806
- _archive_recovered_target(path, receipt, entry, archive_reason)
807
- return
808
- if linked_path != target.resolve():
809
- raise LifecycleError(
810
- f"worktree registration changed during creation recovery: {branch}"
811
- )
812
- if not target_present:
813
- raise LifecycleError(
814
- f"worktree directory is missing while Git registration remains: {branch}"
815
- )
816
- if target.is_symlink() or not target.is_dir():
817
- raise LifecycleError(
818
- f"worktree root type changed during creation recovery: {branch}"
819
- )
820
- metadata = target.stat()
821
- recorded_identity = (entry.get("rootDevice"), entry.get("rootInode"))
822
- if None not in recorded_identity and recorded_identity != (
823
- metadata.st_dev, metadata.st_ino
824
- ):
825
- raise LifecycleError(
826
- f"worktree root identity changed during creation recovery: {branch}"
827
- )
828
- if None in recorded_identity:
829
- if (
830
- not _worktree_backlink_matches(main, target, branch)
831
- or collect_cleanup_facts(main, target, pr_state="none").tracked_files
832
- or collect_cleanup_facts(main, target, pr_state="none").untracked_files
833
- ):
834
- raise LifecycleError(
835
- f"worktree creation identity was not journaled before recovery: {branch}"
836
- )
837
- entry["rootDevice"] = metadata.st_dev
838
- entry["rootInode"] = metadata.st_ino
839
- recorded_identity = (metadata.st_dev, metadata.st_ino)
840
- write_receipt(path, receipt)
841
-
842
- facts = collect_cleanup_facts(main, target, pr_state="none")
843
- baseline_digest = entry.get("artifactBaselineDigest")
844
- if not baseline_digest:
845
- if facts.tracked_files or facts.untracked_files:
846
- raise LifecycleError(
847
- "creation baseline is missing while worktree changes remain"
848
- )
849
- setup_created: tuple[str, ...] = ()
850
- tracked_evidence = _tracked_evidence(target)
851
- else:
852
- baseline = load_artifact_baseline(target)
853
- if (
854
- baseline.digest != baseline_digest
855
- or baseline.setup_head != expected_oid
856
- or (baseline.root_device, baseline.root_inode) != recorded_identity
857
- ):
858
- raise LifecycleError("creation baseline changed or is incoherent")
859
- initial = set(baseline.initial_untracked_files)
860
- current = set(facts.untracked_files)
861
- if not initial.issubset(current):
862
- raise LifecycleError(
863
- "creation baseline inventory changed before recovery"
864
- )
865
- evidence = entry.get("setupCreatedFiles")
866
- tracked_evidence = entry.get("setupTrackedEvidence")
867
- evidence_state = entry.get("evidenceState")
868
- if evidence_state == "pending":
869
- current_tracked = _tracked_evidence(target)
870
- current_created = tuple(sorted(current.difference(initial)))
871
- current_digest = _inventory_digest(current_tracked, current_created)
872
- if not facts.tracked_files and not current_created:
873
- entry["setupTrackedEvidence"] = current_tracked
874
- entry["setupCreatedFiles"] = []
875
- entry["setupCreatedPaths"] = []
876
- entry["setupInventoryDigest"] = current_digest
877
- entry["evidenceState"] = "complete"
878
- entry.pop("evidenceFailureClass", None)
879
- write_receipt(path, receipt)
880
- evidence = []
881
- tracked_evidence = current_tracked
882
- elif current_digest == entry.get("setupInventoryDigest"):
883
- _capture_setup_created_evidence(main, target, entry)
884
- write_receipt(path, receipt)
885
- evidence = entry.get("setupCreatedFiles")
886
- tracked_evidence = entry.get("setupTrackedEvidence")
887
- else:
888
- raise LifecycleError(
889
- "creation recovery evidence is pending and inventory changed"
890
- )
891
- if not isinstance(evidence, list) or not all(
892
- isinstance(item, dict)
893
- and isinstance(item.get("path"), str)
894
- and item.get("kind") in {"regular", "symlink"}
895
- for item in evidence
896
- ):
897
- raise LifecycleError("setup-created file evidence is missing or incoherent")
898
- if (
899
- not isinstance(tracked_evidence, dict)
900
- or not isinstance(tracked_evidence.get("paths"), list)
901
- ):
902
- raise LifecycleError("setup-created tracked evidence is missing or incoherent")
903
- current_tracked = _tracked_evidence(target)
904
- clean_tracked = _tracked_evidence_at_created_oid()
905
- if current_tracked != clean_tracked and current_tracked != tracked_evidence:
906
- raise LifecycleError("tracked setup changes changed after failed setup")
907
- evidenced_paths = {item["path"] for item in evidence}
908
- unexpected = current.difference(initial).difference(evidenced_paths)
909
- if unexpected:
910
- raise LifecycleError(
911
- "foreign untracked files appeared after failed setup: "
912
- + ", ".join(sorted(unexpected))
913
- )
914
- setup_created = tuple(sorted(current.intersection(evidenced_paths)))
915
- if initial:
916
- raise LifecycleError(
917
- "pre-setup untracked files remain protected: "
918
- + ", ".join(sorted(initial))
919
- )
920
-
921
- if tracked_evidence != _tracked_evidence_at_created_oid():
922
- _revalidate_creation_safety(main, args, path, receipt, entry)
923
- current_tracked = _tracked_evidence(target)
924
- if current_tracked == tracked_evidence:
925
- paths = tracked_evidence["paths"]
926
- if paths:
927
- _revalidate_creation_safety(main, args, path, receipt, entry)
928
- restored = run(
929
- [
930
- "git", "restore", f"--source={expected_oid}",
931
- "--staged", "--worktree", "--", *paths,
932
- ],
933
- cwd=target,
934
- check=False,
935
- )
936
- if restored.returncode != 0:
937
- raise LifecycleError(
938
- "bounded tracked restoration failed during creation recovery"
939
- )
940
- elif current_tracked != _tracked_evidence_at_created_oid():
941
- raise LifecycleError("tracked setup changes changed after failed setup")
942
- _revalidate_creation_safety(main, args, path, receipt, entry)
943
- with verified_worktree_root(
944
- target,
945
- entry["rootDevice"],
946
- entry["rootInode"],
947
- ) as descriptor:
948
- for relative in setup_created:
949
- _revalidate_creation_safety(main, args, path, receipt, entry)
950
- expected = next(
951
- item for item in entry["setupCreatedFiles"]
952
- if item["path"] == relative
953
- )
954
- remove_contained_untracked(descriptor, expected)
955
- latest = collect_cleanup_facts(main, target, pr_state="none")
956
- if latest.tracked_files or latest.untracked_files:
957
- raise LifecycleError("creation recovery inventory changed before removal")
958
- _revalidate_creation_safety(main, args, path, receipt, entry)
959
- if not delete_owned_refs_prepared(
960
- main,
961
- branch,
962
- proof_ref,
963
- expected_oid,
964
- entry["createdOid"],
965
- f"refs/tags/wave-active/{args.anchor}",
966
- receipt["claimOid"],
967
- remove_worktree=target,
968
- root_identity=(entry["rootDevice"], entry["rootInode"]),
969
- ):
970
- raise LifecycleError(
971
- f"locked worktree/ref removal failed during creation recovery: {branch}"
972
- )
973
- if os.path.lexists(target) or branch in worktree_branches(main):
974
- raise LifecycleError("worktree still exists after creation recovery")
975
- _archive_recovered_target(path, receipt, entry, archive_reason)
976
-
977
-
978
- def recover_creation(main: Path, args: argparse.Namespace) -> dict[str, Any]:
979
- with receipt_lock(main, args.anchor):
980
- path, receipt = read_receipt(main, args.anchor, args.owner)
981
- entry = next(
982
- (
983
- candidate for candidate in receipt["targets"]
984
- if candidate.get("branch") == args.branch
985
- ),
986
- None,
987
- )
988
- if entry is None:
989
- raise LifecycleError(f"no receipt target for recovery: {args.branch}")
990
- if entry.get("state") not in {
991
- "provisional",
992
- "worktree-pending",
993
- "baseline-pending",
994
- "setting-up",
995
- "recovery-pending",
996
- }:
997
- raise LifecycleError(
998
- f"target is not a recoverable creation attempt: {args.branch}: "
999
- f"{entry.get('state', '<missing>')}"
1000
- )
1001
- entry["state"] = "recovery-pending"
1002
- entry.pop("failure", None)
1003
- write_receipt(path, receipt)
1004
- _recover_creation_entry(
1005
- main,
1006
- args,
1007
- path,
1008
- receipt,
1009
- entry,
1010
- archive_reason="explicit creation recovery",
1011
- )
1012
- return {
1013
- "recovered": True,
1014
- "branch": args.branch,
1015
- "receipt": str(path),
1016
- }
1017
-
1018
-
1019
- def worktree_branches(main: Path) -> dict[str, Path]:
1020
- output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
1021
- result: dict[str, Path] = {}
1022
- current: Path | None = None
1023
- for line in output.splitlines():
1024
- if line.startswith("worktree "):
1025
- current = Path(line.split(" ", 1)[1]).resolve()
1026
- elif line.startswith("branch refs/heads/") and current is not None:
1027
- result[line.removeprefix("branch refs/heads/")] = current
1028
- return result
1029
-
1030
-
1031
- def lexical_worktree_branches(main: Path) -> dict[str, Path]:
1032
- """Return Git's registered paths without resolving a substituted symlink."""
1033
- output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
1034
- result: dict[str, Path] = {}
1035
- current: Path | None = None
1036
- for line in output.splitlines():
1037
- if line.startswith("worktree "):
1038
- current = Path(line.split(" ", 1)[1]).absolute()
1039
- elif line.startswith("branch refs/heads/") and current is not None:
1040
- result[line.removeprefix("branch refs/heads/")] = current
1041
- return result
1042
-
1043
-
1044
- def prepare_target_root_nofollow(
1045
- main: Path,
1046
- target: Path,
1047
- ) -> tuple[os.stat_result, os.stat_result]:
1048
- """Create the lexical target through directory descriptors only."""
1049
- relative = target.relative_to(main.resolve())
1050
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1051
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1052
- descriptors: list[int] = []
1053
- current = os.open(main, directory_flags | no_follow)
1054
- try:
1055
- descriptors.append(current)
1056
- for component in relative.parts[:-1]:
1057
- try:
1058
- child = os.open(
1059
- component, directory_flags | no_follow, dir_fd=current
1060
- )
1061
- except FileNotFoundError:
1062
- os.mkdir(component, mode=0o700, dir_fd=current)
1063
- child = os.open(
1064
- component, directory_flags | no_follow, dir_fd=current
1065
- )
1066
- current = child
1067
- descriptors.append(current)
1068
- parent_metadata = os.fstat(current)
1069
- os.mkdir(relative.name, mode=0o700, dir_fd=current)
1070
- root_metadata = os.stat(
1071
- relative.name, dir_fd=current, follow_symlinks=False
1072
- )
1073
- if not stat.S_ISDIR(root_metadata.st_mode):
1074
- raise LifecycleError("new session worktree root is not a directory")
1075
- return parent_metadata, root_metadata
1076
- except OSError as error:
1077
- raise LifecycleError(
1078
- "new session worktree parent/root creation is unsafe"
1079
- ) from error
1080
- finally:
1081
- for descriptor in reversed(descriptors):
1082
- os.close(descriptor)
1083
-
1084
-
1085
- def verify_target_parent_nofollow(
1086
- main: Path,
1087
- target: Path,
1088
- expected_identity: tuple[int, int],
1089
- ) -> None:
1090
- relative = target.relative_to(main.resolve())
1091
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1092
- no_follow = getattr(os, "O_NOFOLLOW", 0)
1093
- descriptors: list[int] = []
1094
- current = os.open(main, directory_flags | no_follow)
1095
- try:
1096
- descriptors.append(current)
1097
- for component in relative.parts[:-1]:
1098
- current = os.open(
1099
- component, directory_flags | no_follow, dir_fd=current
1100
- )
1101
- descriptors.append(current)
1102
- metadata = os.fstat(current)
1103
- if (metadata.st_dev, metadata.st_ino) != expected_identity:
1104
- raise LifecycleError("new session worktree parent identity changed")
1105
- except OSError as error:
1106
- raise LifecycleError(
1107
- "new session worktree parent identity changed"
1108
- ) from error
1109
- finally:
1110
- for descriptor in reversed(descriptors):
1111
- os.close(descriptor)
1112
-
1113
-
1114
- def verify_created_worktree_root(
1115
- main: Path,
1116
- target: Path,
1117
- branch: str,
1118
- *,
1119
- expected_identity: tuple[int, int] | None = None,
1120
- expected_parent_identity: tuple[int, int] | None = None,
1121
- ) -> os.stat_result:
1122
- """Bind setup to the lexical linked-worktree directory without symlink following."""
1123
- lexical_target = target.absolute()
1124
- if expected_parent_identity is not None:
1125
- verify_target_parent_nofollow(main, target, expected_parent_identity)
1126
- if lexical_worktree_branches(main).get(branch) != lexical_target:
1127
- raise LifecycleError("new session worktree registration is incoherent")
1128
- try:
1129
- metadata = target.lstat()
1130
- if not stat.S_ISDIR(metadata.st_mode):
1131
- raise LifecycleError("new session worktree root is not a directory")
1132
- with verified_worktree_root(
1133
- target,
1134
- metadata.st_dev,
1135
- metadata.st_ino,
1136
- ) as descriptor:
1137
- git_file = os.open(
1138
- ".git",
1139
- os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
1140
- dir_fd=descriptor,
1141
- )
1142
- try:
1143
- opened = os.fstat(git_file)
1144
- if not stat.S_ISREG(opened.st_mode):
1145
- raise LifecycleError("new session worktree backlink is incoherent")
1146
- backlink = os.read(git_file, 16 * 1024).decode("utf-8").strip()
1147
- finally:
1148
- os.close(git_file)
1149
- except OSError as error:
1150
- raise LifecycleError("new session worktree root identity is incoherent") from error
1151
- if not backlink.startswith("gitdir: "):
1152
- raise LifecycleError("new session worktree backlink is incoherent")
1153
- git_dir = Path(backlink.removeprefix("gitdir: "))
1154
- common = common_git_dir(main)
1155
- try:
1156
- git_dir.relative_to(common / "worktrees")
1157
- git_dir_metadata = git_dir.lstat()
1158
- if not stat.S_ISDIR(git_dir_metadata.st_mode):
1159
- raise ValueError
1160
- recorded_backlink = (git_dir / "gitdir").read_text(encoding="utf-8").strip()
1161
- except (OSError, ValueError) as error:
1162
- raise LifecycleError("new session worktree backlink is incoherent") from error
1163
- if Path(recorded_backlink).absolute() != lexical_target / ".git":
1164
- raise LifecycleError("new session worktree backlink is incoherent")
1165
- identity = (metadata.st_dev, metadata.st_ino)
1166
- if expected_identity is not None and identity != expected_identity:
1167
- raise LifecycleError("new session worktree root identity changed")
1168
- return metadata
1169
-
1170
-
1171
- def seal(main: Path, args: argparse.Namespace) -> dict[str, Any]:
1172
- with receipt_lock(main, args.anchor):
1173
- path, receipt = read_receipt(main, args.anchor, args.owner)
1174
- if receipt.get("state") == "sealed":
1175
- return receipt_report(path, receipt)
1176
- if receipt.get("state") != "open" or (
1177
- not receipt["targets"] and not receipt.get("recoveredTargets")
1178
- ):
1179
- raise LifecycleError(
1180
- "only an open receipt with active or recovered targets can be sealed"
1181
- )
1182
- linked = worktree_branches(main)
1183
- for entry in receipt["targets"]:
1184
- branch = entry["branch"]
1185
- if entry.get("state") != "active":
1186
- raise LifecycleError(
1187
- f"session target is not ready to seal: {branch}: "
1188
- f"{entry.get('state', '<missing>')}"
1189
- )
1190
- target = Path(entry["worktree"])
1191
- if linked.get(branch) != target.resolve():
1192
- raise LifecycleError(f"session worktree identity changed before seal: {branch}")
1193
- metadata = target.stat()
1194
- if (metadata.st_dev, metadata.st_ino) != (
1195
- entry["rootDevice"], entry["rootInode"]
1196
- ):
1197
- raise LifecycleError(f"session worktree root changed before seal: {branch}")
1198
- if run(["git", "status", "--porcelain"], cwd=target).stdout.strip():
1199
- raise LifecycleError(f"session worktree is dirty before seal: {branch}")
1200
- expected = resolve_ref(main, f"refs/heads/{branch}")
1201
- if expected is None or run(
1202
- ["git", "merge-base", "--is-ancestor", entry["createdOid"], expected],
1203
- cwd=main,
1204
- check=False,
1205
- ).returncode != 0:
1206
- raise LifecycleError(f"session branch moved outside its created history: {branch}")
1207
- proof_ref = validated_proof_ref(receipt, entry)
1208
- if resolve_ref(main, proof_ref) != entry["createdOid"]:
1209
- raise LifecycleError(f"session ownership proof changed before seal: {branch}")
1210
- entry["expectedOid"] = expected
1211
- entry["state"] = "sealed"
1212
- receipt["state"] = "sealed"
1213
- write_receipt(path, receipt)
1214
- return receipt_report(path, receipt)
1215
-
1216
-
1217
- def commit_parents(repo: Path, commit: str) -> list[str]:
1218
- line = run(["git", "rev-list", "--parents", "-n", "1", commit], cwd=repo).stdout
1219
- return line.strip().split()[1:]
1220
-
1221
-
1222
- def content_empty(repo: Path, commit: str, first_parent: str) -> bool:
1223
- return run(
1224
- ["git", "diff-tree", "--quiet", first_parent, commit],
1225
- cwd=repo,
1226
- check=False,
1227
- ).returncode == 0
1228
-
1229
-
1230
- def patch_id(repo: Path, commit: str, first_parent: str) -> str | None:
1231
- diff = run(
1232
- [
1233
- "git", "diff", "--binary", "--full-index", "--no-renames",
1234
- first_parent, commit,
1235
- ],
1236
- cwd=repo,
1237
- ).stdout
1238
- if not diff:
1239
- return None
1240
- result = subprocess.run(
1241
- ["git", "patch-id", "--stable"],
1242
- cwd=repo,
1243
- input=diff,
1244
- capture_output=True,
1245
- text=True,
1246
- check=False,
1247
- )
1248
- if result.returncode != 0 or not result.stdout.strip():
1249
- return None
1250
- return result.stdout.split()[0]
1251
-
1252
-
1253
- def is_ancestor(repo: Path, commit: str, target: str) -> bool:
1254
- return run(
1255
- ["git", "merge-base", "--is-ancestor", commit, target],
1256
- cwd=repo,
1257
- check=False,
1258
- ).returncode == 0
1259
-
1260
-
1261
- def main_patch_index(repo: Path, base: str, main_oid: str) -> Counter[str]:
1262
- commits = run(
1263
- ["git", "rev-list", "--reverse", "--topo-order", f"{base}..{main_oid}"],
1264
- cwd=repo,
1265
- ).stdout.splitlines()
1266
- patches: Counter[str] = Counter()
1267
- for commit in commits:
1268
- parents = commit_parents(repo, commit)
1269
- if len(parents) != 1 or content_empty(repo, commit, parents[0]):
1270
- continue
1271
- value = patch_id(repo, commit, parents[0])
1272
- if value:
1273
- patches[value] += 1
1274
- return patches
1275
-
1276
-
1277
- def integration_report(
1278
- repo: Path,
1279
- created_oid: str,
1280
- expected_oid: str,
1281
- main_oid: str,
1282
- ) -> dict[str, Any]:
1283
- if not is_ancestor(repo, created_oid, main_oid):
1284
- return {
1285
- "integration": "ambiguous",
1286
- "commits": [],
1287
- "reason": "recorded base is no longer an ancestor of canonical main",
1288
- }
1289
- commits = run(
1290
- [
1291
- "git", "rev-list", "--reverse", "--topo-order",
1292
- f"{created_oid}..{expected_oid}",
1293
- ],
1294
- cwd=repo,
1295
- ).stdout.splitlines()
1296
- index = main_patch_index(repo, created_oid, main_oid)
1297
- provisional: list[dict[str, Any]] = []
1298
- owned_patch_counts: Counter[str] = Counter()
1299
- for commit in commits:
1300
- parents = commit_parents(repo, commit)
1301
- if not parents:
1302
- provisional.append({"oid": commit, "status": "ambiguous", "reason": "root commit"})
1303
- continue
1304
- if content_empty(repo, commit, parents[0]):
1305
- provisional.append({
1306
- "oid": commit,
1307
- "status": "ambiguous",
1308
- "reason": "empty commit has no patch identity",
1309
- })
1310
- continue
1311
- if len(parents) != 1:
1312
- provisional.append({
1313
- "oid": commit,
1314
- "status": "ambiguous",
1315
- "reason": "merge commit has no unambiguous patch identity",
1316
- })
1317
- continue
1318
- if is_ancestor(repo, commit, main_oid):
1319
- provisional.append({"oid": commit, "status": "ancestry-merged"})
1320
- continue
1321
- value = patch_id(repo, commit, parents[0])
1322
- if value is None:
1323
- provisional.append({"oid": commit, "status": "ambiguous", "reason": "missing patch-id"})
1324
- continue
1325
- owned_patch_counts[value] += 1
1326
- provisional.append({"oid": commit, "status": "pending", "patchId": value})
1327
-
1328
- for row in provisional:
1329
- value = row.get("patchId")
1330
- if row["status"] != "pending" or value is None:
1331
- continue
1332
- if owned_patch_counts[value] != 1 or index[value] > 1:
1333
- row["status"] = "ambiguous"
1334
- row["reason"] = "patch-id is not one-to-one"
1335
- elif index[value] == 1:
1336
- row["status"] = "patch-equivalent"
1337
- else:
1338
- row["status"] = "unique-patch"
1339
-
1340
- statuses = {row["status"] for row in provisional}
1341
- if "ambiguous" in statuses:
1342
- integration = "ambiguous"
1343
- elif "unique-patch" in statuses:
1344
- integration = "unique-patch"
1345
- elif statuses and statuses <= {"ancestry-merged"}:
1346
- integration = "ancestry-merged"
1347
- else:
1348
- integration = "patch-equivalent"
1349
- return {"integration": integration, "commits": provisional}
1350
-
1351
-
1352
- def assess(main: Path, args: argparse.Namespace) -> tuple[Path, dict[str, Any], dict[str, Any]]:
1353
- path, receipt = read_receipt(main, args.anchor, args.owner)
1354
- if receipt.get("state") not in {"sealed", "tearing-down", "complete"}:
1355
- raise LifecycleError("teardown receipt must be sealed first")
1356
- main_oid = resolve_commit(main, args.main)
1357
- if main_oid is None:
1358
- raise LifecycleError(f"canonical main is not resolvable: {args.main}")
1359
- linked = worktree_branches(main)
1360
- rows = []
1361
- for entry in receipt["targets"]:
1362
- branch = entry["branch"]
1363
- target = Path(entry["worktree"])
1364
- reasons: list[str] = []
1365
- current_oid = resolve_ref(main, f"refs/heads/{branch}")
1366
- proof_ref = validated_proof_ref(receipt, entry)
1367
- proof_oid = resolve_ref(main, proof_ref)
1368
- target_exists = os.path.lexists(target)
1369
- linked_path = linked.get(branch)
1370
- fully_absent = (
1371
- current_oid is None
1372
- and proof_oid is None
1373
- and not target_exists
1374
- and linked_path is None
1375
- )
1376
- if entry.get("removed"):
1377
- if fully_absent:
1378
- rows.append({
1379
- "branch": branch,
1380
- "worktree": str(target),
1381
- "expectedOid": entry.get("expectedOid"),
1382
- "currentOid": current_oid,
1383
- "integration": "already-removed",
1384
- "commits": [],
1385
- "reasons": [],
1386
- "removable": True,
1387
- "scratchFiles": [],
1388
- "scratchEvidence": [],
1389
- })
1390
- else:
1391
- rows.append({
1392
- "branch": branch,
1393
- "worktree": str(target),
1394
- "expectedOid": entry.get("expectedOid"),
1395
- "currentOid": current_oid,
1396
- "integration": "ambiguous",
1397
- "commits": [],
1398
- "reasons": ["removed target was recreated"],
1399
- "removable": False,
1400
- "scratchFiles": [],
1401
- "scratchEvidence": [],
1402
- })
1403
- continue
1404
- if (
1405
- fully_absent
1406
- and entry.get("teardownPhase") == "ref-deletion-pending"
1407
- ):
1408
- rows.append({
1409
- "branch": branch,
1410
- "worktree": str(target),
1411
- "expectedOid": entry.get("expectedOid"),
1412
- "currentOid": current_oid,
1413
- "integration": "already-removed",
1414
- "commits": [],
1415
- "reasons": [],
1416
- "removable": True,
1417
- "scratchFiles": [],
1418
- "scratchEvidence": [],
1419
- })
1420
- continue
1421
- if fully_absent:
1422
- rows.append({
1423
- "branch": branch,
1424
- "worktree": str(target),
1425
- "expectedOid": entry.get("expectedOid"),
1426
- "currentOid": current_oid,
1427
- "integration": "ambiguous",
1428
- "commits": [],
1429
- "reasons": ["owned target disappeared outside session teardown"],
1430
- "removable": False,
1431
- "scratchFiles": [],
1432
- "scratchEvidence": [],
1433
- })
1434
- continue
1435
- expected_oid = entry.get("expectedOid")
1436
- if not expected_oid or current_oid != expected_oid:
1437
- reasons.append(
1438
- f"unexpected OID: expected {expected_oid or '<unsealed>'}, "
1439
- f"found {current_oid or '<missing>'}"
1440
- )
1441
- if proof_oid != entry.get("createdOid"):
1442
- reasons.append("session ownership proof is missing or changed")
1443
- profile = load_profile(Path(entry["profile"]))
1444
- if branch in profile.protected_branches:
1445
- reasons.append(f"protected branch: {branch}")
1446
- state = pr_state(args.gh_command, main, branch)
1447
- if state == "open":
1448
- reasons.append("open PR")
1449
- integration = (
1450
- integration_report(main, entry["createdOid"], expected_oid, main_oid)
1451
- if expected_oid and current_oid == expected_oid
1452
- else {"integration": "ambiguous", "commits": []}
1453
- )
1454
- if integration["integration"] == "unique-patch":
1455
- reasons.append("unique patch content is not represented on canonical main")
1456
- elif integration["integration"] == "ambiguous":
1457
- reasons.append("ambiguous patch identity")
1458
-
1459
- scratch: list[str] = []
1460
- scratch_evidence: list[dict[str, Any]] = []
1461
- if target_exists or linked_path is not None:
1462
- if not target_exists and linked_path is not None:
1463
- reasons.append(
1464
- "worktree directory is missing while Git registration remains"
1465
- )
1466
- elif target_exists and (target.is_symlink() or not target.is_dir()):
1467
- reasons.append("worktree root type changed")
1468
- elif linked_path != target.resolve():
1469
- reasons.append("worktree registration no longer matches receipt")
1470
- elif target_exists:
1471
- metadata = target.stat()
1472
- if (metadata.st_dev, metadata.st_ino) != (
1473
- entry["rootDevice"], entry["rootInode"]
1474
- ):
1475
- reasons.append("worktree root identity changed")
1476
- verified_scratch: tuple[str, ...] = ()
1477
- try:
1478
- baseline = load_artifact_baseline(target)
1479
- if (
1480
- baseline.digest != entry.get("artifactBaselineDigest")
1481
- or baseline.setup_head != entry["createdOid"]
1482
- ):
1483
- raise LifecycleError(
1484
- "artifact provenance baseline changed or is incoherent"
1485
- )
1486
- evidence = verified_landing_scratch_evidence(
1487
- profile,
1488
- target,
1489
- expected_baseline_digest=entry["artifactBaselineDigest"],
1490
- )
1491
- scratch_evidence = list(evidence)
1492
- verified_scratch = tuple(item["path"] for item in evidence)
1493
- except (LifecycleError, KeyError) as error:
1494
- reasons.append(f"artifact provenance baseline stop: {error}")
1495
- facts = collect_cleanup_facts(
1496
- main,
1497
- target,
1498
- merge_target=args.main,
1499
- pr_state=state,
1500
- )
1501
- cleanup = classify_cleanup(
1502
- profile,
1503
- facts,
1504
- verified_scratch_files=verified_scratch,
1505
- )
1506
- try:
1507
- cleanup = bind_cleanup_scratch_evidence(
1508
- profile,
1509
- cleanup,
1510
- scratch_evidence,
1511
- require_generator_evidence=True,
1512
- )
1513
- scratch_evidence = list(cleanup.scratch_evidence)
1514
- except LifecycleError as error:
1515
- reasons.append(f"scratch evidence stop: {error}")
1516
- reasons.extend(
1517
- reason for reason in cleanup.reasons
1518
- if not reason.startswith("unmerged branch:")
1519
- )
1520
- scratch = list(cleanup.scratch_files)
1521
- rows.append({
1522
- "branch": branch,
1523
- "worktree": str(target),
1524
- "expectedOid": expected_oid,
1525
- "currentOid": current_oid,
1526
- "integration": integration["integration"],
1527
- "commits": integration["commits"],
1528
- "reasons": list(dict.fromkeys(reasons)),
1529
- "removable": not reasons,
1530
- "scratchFiles": scratch,
1531
- "scratchEvidence": scratch_evidence,
1532
- })
1533
- report = {
1534
- "receipt": str(path),
1535
- "anchor": args.anchor,
1536
- "owner": args.owner,
1537
- "main": args.main,
1538
- "mainOid": main_oid,
1539
- "targets": rows,
1540
- "removable": all(row["removable"] for row in rows),
1541
- }
1542
- return path, receipt, report
1543
-
1544
-
1545
- def inspect(main: Path, args: argparse.Namespace) -> dict[str, Any]:
1546
- return assess(main, args)[2]
1547
-
1548
-
1549
- def revalidate_target(
1550
- main: Path,
1551
- args: argparse.Namespace,
1552
- receipt_path_value: Path,
1553
- receipt: dict[str, Any],
1554
- entry: dict[str, Any],
1555
- main_oid: str,
1556
- ) -> dict[str, Any]:
1557
- if resolve_commit(main, args.main) != main_oid:
1558
- raise LifecycleError("canonical main moved before target mutation")
1559
- current_path, current_receipt, report = assess(main, args)
1560
- if current_path != receipt_path_value or current_receipt != receipt:
1561
- raise LifecycleError("active claim or teardown receipt changed before mutation")
1562
- row = next(
1563
- (candidate for candidate in report["targets"]
1564
- if candidate["branch"] == entry["branch"]),
1565
- None,
1566
- )
1567
- if row is None:
1568
- raise LifecycleError("exact receipt target disappeared before mutation")
1569
- if not row["removable"] or row["integration"] == "already-removed":
1570
- detail = ", ".join(row["reasons"]) or row["integration"]
1571
- raise LifecycleError(
1572
- f"target changed before mutation: {entry['branch']}: {detail}"
1573
- )
1574
- # assess queries PR state after reading the receipt. Close that race with a
1575
- # final claim/receipt, profile, PR and ref/proof pass.
1576
- revalidate_owned_safety(
1577
- main, args, receipt_path_value, receipt, entry,
1578
- expected_oid=entry["expectedOid"],
1579
- error_suffix="target mutation",
1580
- )
1581
- return row
1582
-
1583
-
1584
- def revalidate_owned_safety(
1585
- main: Path,
1586
- args: argparse.Namespace,
1587
- receipt_path_value: Path,
1588
- receipt: dict[str, Any],
1589
- entry: dict[str, Any],
1590
- *,
1591
- expected_oid: str,
1592
- error_suffix: str,
1593
- ) -> None:
1594
- branch = entry["branch"]
1595
- if pr_state(args.gh_command, main, branch) == "open":
1596
- raise LifecycleError(f"open PR before {error_suffix}: {branch}")
1597
- current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
1598
- if current_path != receipt_path_value or current_receipt != receipt:
1599
- raise LifecycleError(
1600
- f"active claim or teardown receipt changed before {error_suffix}"
1601
- )
1602
- profile = load_profile(Path(entry["profile"]))
1603
- if branch in profile.protected_branches:
1604
- raise LifecycleError(f"protected branch before {error_suffix}: {branch}")
1605
- if resolve_ref(main, f"refs/heads/{branch}") != expected_oid:
1606
- raise LifecycleError(f"unexpected OID before {error_suffix}: {branch}")
1607
- proof_ref = validated_proof_ref(receipt, entry)
1608
- if resolve_ref(main, proof_ref) != entry["createdOid"]:
1609
- raise LifecycleError(f"session ownership proof changed before {error_suffix}: {branch}")
1610
-
1611
-
1612
- def revalidate_ref_cleanup(
1613
- main: Path,
1614
- args: argparse.Namespace,
1615
- receipt_path_value: Path,
1616
- receipt: dict[str, Any],
1617
- entry: dict[str, Any],
1618
- main_oid: str,
1619
- ) -> None:
1620
- if resolve_commit(main, args.main) != main_oid:
1621
- raise LifecycleError("canonical main moved before branch cleanup")
1622
- branch = entry["branch"]
1623
- revalidate_owned_safety(
1624
- main, args, receipt_path_value, receipt, entry,
1625
- expected_oid=entry["expectedOid"],
1626
- error_suffix="branch cleanup",
1627
- )
1628
- target = Path(entry["worktree"])
1629
- linked = worktree_branches(main)
1630
- target_present = os.path.lexists(target)
1631
- if target_present:
1632
- metadata = target.lstat()
1633
- if (
1634
- not stat.S_ISDIR(metadata.st_mode)
1635
- or
1636
- linked.get(branch) != target.resolve()
1637
- or (metadata.st_dev, metadata.st_ino)
1638
- != (entry["rootDevice"], entry["rootInode"])
1639
- ):
1640
- raise LifecycleError(
1641
- f"worktree identity changed before branch cleanup: {branch}"
1642
- )
1643
- elif branch in linked:
1644
- raise LifecycleError(
1645
- f"worktree registration changed before branch cleanup: {branch}"
1646
- )
1647
- elif entry.get("teardownPhase") != "ref-deletion-pending":
1648
- raise LifecycleError(f"owned worktree disappeared before branch cleanup: {branch}")
1649
-
1650
-
1651
- def canonical_ref(repo: Path, rev: str) -> str | None:
1652
- result = run(
1653
- ["git", "rev-parse", "--symbolic-full-name", rev],
1654
- cwd=repo,
1655
- check=False,
1656
- )
1657
- value = result.stdout.strip()
1658
- return value if result.returncode == 0 and value.startswith("refs/") else None
1659
-
1660
-
1661
- def immutable_oid(value: str) -> bool:
1662
- return len(value) == 40 and all(character in "0123456789abcdef" for character in value)
1663
-
1664
-
1665
- def teardown(main: Path, args: argparse.Namespace) -> dict[str, Any]:
1666
- with receipt_lock(main, args.anchor):
1667
- path, receipt, preview = assess(main, args)
1668
- main_ref = canonical_ref(main, args.main)
1669
- if main_ref is None and not immutable_oid(args.main):
1670
- raise LifecycleError(
1671
- "canonical main must be a ref or immutable full commit OID"
1672
- )
1673
- if not preview["removable"]:
1674
- blockers = [
1675
- f"{row['branch']}: {', '.join(row['reasons'])}"
1676
- for row in preview["targets"] if row["reasons"]
1677
- ]
1678
- raise LifecycleError("; ".join(blockers))
1679
- _, latest_receipt, latest = assess(main, args)
1680
- if latest != preview or latest_receipt != receipt:
1681
- raise LifecycleError("teardown inventory changed before removal")
1682
- if resolve_commit(main, args.main) != preview["mainOid"]:
1683
- raise LifecycleError("canonical main moved before teardown")
1684
-
1685
- by_branch = {row["branch"]: row for row in latest["targets"]}
1686
- # Persist every recovery OID before the first mutation. The receipt is
1687
- # an audit/recovery archive even if the process stops between targets.
1688
- for entry in receipt["targets"]:
1689
- if not entry.get("removed"):
1690
- entry["recoveryOid"] = entry["expectedOid"]
1691
- receipt["state"] = "tearing-down"
1692
- write_receipt(path, receipt)
1693
-
1694
- for entry in receipt["targets"]:
1695
- row = by_branch[entry["branch"]]
1696
- if row["integration"] == "already-removed":
1697
- entry["removed"] = True
1698
- continue
1699
- row = revalidate_target(
1700
- main,
1701
- args,
1702
- path,
1703
- receipt,
1704
- entry,
1705
- preview["mainOid"],
1706
- )
1707
- if (
1708
- row["scratchFiles"] != by_branch[entry["branch"]]["scratchFiles"]
1709
- or row["scratchEvidence"]
1710
- != by_branch[entry["branch"]]["scratchEvidence"]
1711
- ):
1712
- raise LifecycleError(
1713
- f"scratch evidence changed before target mutation: "
1714
- f"{entry['branch']}"
1715
- )
1716
- target = Path(entry["worktree"])
1717
- if os.path.lexists(target):
1718
- with verified_worktree_root(
1719
- target,
1720
- entry["rootDevice"],
1721
- entry["rootInode"],
1722
- ) as descriptor:
1723
- profile = load_profile(Path(entry["profile"]))
1724
- remove_authorized_scratch(
1725
- profile,
1726
- descriptor,
1727
- row["scratchFiles"],
1728
- row["scratchEvidence"],
1729
- )
1730
- after_scratch = revalidate_target(
1731
- main,
1732
- args,
1733
- path,
1734
- receipt,
1735
- entry,
1736
- preview["mainOid"],
1737
- )
1738
- if after_scratch["scratchFiles"]:
1739
- raise LifecycleError(
1740
- f"scratch inventory changed before worktree removal: "
1741
- f"{entry['branch']}"
1742
- )
1743
- revalidate_ref_cleanup(
1744
- main,
1745
- args,
1746
- path,
1747
- receipt,
1748
- entry,
1749
- preview["mainOid"],
1750
- )
1751
- entry["teardownPhase"] = "ref-deletion-pending"
1752
- write_receipt(path, receipt)
1753
- revalidate_ref_cleanup(
1754
- main,
1755
- args,
1756
- path,
1757
- receipt,
1758
- entry,
1759
- preview["mainOid"],
1760
- )
1761
- if not delete_owned_refs_prepared(
1762
- main,
1763
- entry["branch"],
1764
- entry["proofRef"],
1765
- entry["expectedOid"],
1766
- entry["createdOid"],
1767
- f"refs/tags/wave-active/{args.anchor}",
1768
- receipt["claimOid"],
1769
- remove_worktree=target if os.path.lexists(target) else None,
1770
- main_ref=main_ref,
1771
- main_oid=preview["mainOid"],
1772
- root_identity=(
1773
- (entry["rootDevice"], entry["rootInode"])
1774
- if os.path.lexists(target) else None
1775
- ),
1776
- ):
1777
- raise LifecycleError(
1778
- f"locked worktree/ref cleanup stopped: {entry['branch']}"
1779
- )
1780
- entry["removed"] = True
1781
- entry["teardownPhase"] = "removed"
1782
- write_receipt(path, receipt)
1783
- receipt["state"] = "complete"
1784
- write_receipt(path, receipt)
1785
- result = receipt_report(path, receipt)
1786
- result["removed"] = True
1787
- return result
1788
-
1789
-
1790
- def receipt_report(path: Path, receipt: dict[str, Any]) -> dict[str, Any]:
1791
- return {
1792
- "receipt": str(path),
1793
- "anchor": receipt["anchor"],
1794
- "owner": receipt["owner"],
1795
- "baseOid": receipt["baseOid"],
1796
- "state": receipt["state"],
1797
- "targets": receipt["targets"],
1798
- }
1799
-
1800
-
1801
- def parser() -> argparse.ArgumentParser:
1802
- root = argparse.ArgumentParser()
1803
- commands = root.add_subparsers(dest="action", required=True)
1804
-
1805
- def common(name: str) -> argparse.ArgumentParser:
1806
- command = commands.add_parser(name)
1807
- command.add_argument("--anchor", required=True)
1808
- command.add_argument("--owner", required=True)
1809
- return command
1810
-
1811
- begin_parser = common("begin")
1812
- begin_parser.add_argument("--base", default="origin/main")
1813
-
1814
- create_parser = common("create")
1815
- create_parser.add_argument(
1816
- "--profile", default="docs/agents/workflow-capabilities.json"
1817
- )
1818
- create_parser.add_argument("--base")
1819
- create_parser.add_argument("--gh-command", default="gh")
1820
- create_parser.add_argument("issue")
1821
- create_parser.add_argument("slug")
1822
- create_parser.add_argument("branch_type", nargs="?", default="feat")
1823
-
1824
- recover_parser = common("recover")
1825
- recover_parser.add_argument("--branch", required=True)
1826
- recover_parser.add_argument("--gh-command", default="gh")
1827
-
1828
- common("seal")
1829
- for name in ("inspect", "teardown"):
1830
- command = common(name)
1831
- command.add_argument("--main", default="origin/main")
1832
- command.add_argument("--gh-command", default="gh")
1833
- return root
1834
-
1835
-
1836
- def main() -> int:
1837
- try:
1838
- args = parser().parse_args()
1839
- repo = main_worktree(Path.cwd())
1840
- actions = {
1841
- "begin": begin,
1842
- "create": create_target,
1843
- "recover": recover_creation,
1844
- "seal": seal,
1845
- "inspect": inspect,
1846
- "teardown": teardown,
1847
- }
1848
- result = actions[args.action](repo, args)
1849
- print(json.dumps(result, ensure_ascii=False, indent=2))
1850
- return 0
1851
- except LifecycleError as error:
1852
- print(f"STOP: {error}", file=sys.stderr)
1853
- return 1
1854
-
1855
-
1856
- if __name__ == "__main__":
1857
- raise SystemExit(main())