@ikon85/agent-workflow-kit 0.37.0 → 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.
@@ -2,10 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import os
5
7
  import re
8
+ import stat
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass, replace
6
11
  from fnmatch import fnmatchcase
7
- from dataclasses import dataclass
8
- from pathlib import Path
12
+ from hashlib import sha256
13
+ from pathlib import Path, PurePosixPath
9
14
  from time import time
10
15
  from typing import Any, Callable
11
16
 
@@ -13,6 +18,7 @@ from profile import (
13
18
  LifecycleError,
14
19
  WorktreeProfile,
15
20
  load_profile,
21
+ load_profile_text,
16
22
  local_branch_exists,
17
23
  main_worktree,
18
24
  registered_worktrees,
@@ -21,6 +27,94 @@ from profile import (
21
27
 
22
28
  _BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
23
29
  _BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
30
+ ARTIFACT_BASELINE_FILE = "awkit-artifact-baseline-v1.json"
31
+ LANDING_ATTEMPT_FILE = "awkit-landing-attempt-v1.json"
32
+
33
+
34
+ class BaselineBackfillDeferred(LifecycleError):
35
+ """A safe legacy baseline cannot be captured until consumer state changes."""
36
+
37
+
38
+ def durable_atomic_json(
39
+ path: Path,
40
+ document: dict[str, Any],
41
+ *,
42
+ label: str,
43
+ mode: int = 0o666,
44
+ sort_keys: bool = False,
45
+ ) -> None:
46
+ """Atomically replace one JSON journal and durably publish its directory entry."""
47
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
48
+ try:
49
+ descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
50
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
51
+ json.dump(
52
+ document,
53
+ handle,
54
+ ensure_ascii=False,
55
+ indent=2,
56
+ sort_keys=sort_keys,
57
+ )
58
+ handle.write("\n")
59
+ handle.flush()
60
+ os.fsync(handle.fileno())
61
+ os.replace(temporary, path)
62
+ directory_descriptor = os.open(
63
+ path.parent,
64
+ os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
65
+ )
66
+ try:
67
+ os.fsync(directory_descriptor)
68
+ finally:
69
+ os.close(directory_descriptor)
70
+ except OSError as error:
71
+ temporary.unlink(missing_ok=True)
72
+ raise LifecycleError(f"cannot {label}: {error}") from error
73
+
74
+
75
+ def durable_replace(source: Path, destination: Path, *, label: str) -> None:
76
+ """Rename one journal durably without inspecting or claiming its payload files."""
77
+ try:
78
+ os.replace(source, destination)
79
+ directory_descriptor = os.open(
80
+ destination.parent,
81
+ os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
82
+ )
83
+ try:
84
+ os.fsync(directory_descriptor)
85
+ finally:
86
+ os.close(directory_descriptor)
87
+ except OSError as error:
88
+ raise LifecycleError(f"cannot {label}: {error}") from error
89
+
90
+
91
+ def path_glob_matches(path: str, pattern: str) -> bool:
92
+ """Match POSIX path segments while retaining fnmatch bracket compatibility."""
93
+ path_parts = PurePosixPath(path).parts
94
+ pattern_parts = PurePosixPath(pattern).parts
95
+ memo: dict[tuple[int, int], bool] = {}
96
+
97
+ def matches(path_index: int, pattern_index: int) -> bool:
98
+ key = (path_index, pattern_index)
99
+ if key in memo:
100
+ return memo[key]
101
+ if pattern_index == len(pattern_parts):
102
+ result = path_index == len(path_parts)
103
+ elif pattern_parts[pattern_index] == "**":
104
+ result = matches(path_index, pattern_index + 1) or (
105
+ path_index < len(path_parts)
106
+ and matches(path_index + 1, pattern_index)
107
+ )
108
+ else:
109
+ result = (
110
+ path_index < len(path_parts)
111
+ and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index])
112
+ and matches(path_index + 1, pattern_index + 1)
113
+ )
114
+ memo[key] = result
115
+ return result
116
+
117
+ return matches(0, 0)
24
118
 
25
119
  @dataclass(frozen=True)
26
120
  class RepoFacts:
@@ -49,6 +143,7 @@ class CleanupAssessment:
49
143
  root_device: int
50
144
  root_inode: int
51
145
  scratch_files: tuple[str, ...] = ()
146
+ scratch_evidence: tuple[dict[str, Any], ...] = ()
52
147
 
53
148
  @property
54
149
  def removable(self) -> bool:
@@ -70,6 +165,566 @@ class CleanupFacts:
70
165
  root_inode: int
71
166
 
72
167
 
168
+ @dataclass(frozen=True)
169
+ class ArtifactBaseline:
170
+ worktree: Path
171
+ branch: str
172
+ root_device: int
173
+ root_inode: int
174
+ setup_head: str
175
+ initial_ignored_files: tuple[str, ...]
176
+ initial_untracked_files: tuple[str, ...]
177
+ digest: str
178
+
179
+
180
+ def ignored_file_inventory(worktree: Path) -> tuple[str, ...]:
181
+ result = run(
182
+ [
183
+ "git", "ls-files", "--others", "--ignored",
184
+ "--exclude-standard", "-z",
185
+ ],
186
+ cwd=worktree,
187
+ )
188
+ return tuple(sorted(path for path in result.stdout.split("\0") if path))
189
+
190
+
191
+ def untracked_file_inventory(worktree: Path) -> tuple[str, ...]:
192
+ ordinary = run(
193
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
194
+ cwd=worktree,
195
+ ).stdout.split("\0")
196
+ return tuple(sorted(
197
+ set(path for path in ordinary if path).union(ignored_file_inventory(worktree))
198
+ ))
199
+
200
+
201
+ def artifact_baseline_path(worktree: Path) -> Path:
202
+ result = run(
203
+ ["git", "rev-parse", "--absolute-git-dir"],
204
+ cwd=worktree,
205
+ )
206
+ git_dir = Path(result.stdout.strip())
207
+ if not git_dir.is_absolute():
208
+ raise LifecycleError("artifact provenance baseline git dir is not absolute")
209
+ return git_dir / ARTIFACT_BASELINE_FILE
210
+
211
+
212
+ def _baseline_payload(
213
+ *,
214
+ worktree: Path,
215
+ branch: str,
216
+ root_device: int,
217
+ root_inode: int,
218
+ setup_head: str,
219
+ initial_ignored_files: tuple[str, ...],
220
+ initial_untracked_files: tuple[str, ...],
221
+ ) -> dict[str, Any]:
222
+ return {
223
+ "contractVersion": 2,
224
+ "worktree": str(worktree),
225
+ "branch": branch,
226
+ "rootDevice": root_device,
227
+ "rootInode": root_inode,
228
+ "setupHead": setup_head,
229
+ "initialIgnoredFiles": list(initial_ignored_files),
230
+ "initialUntrackedFiles": list(initial_untracked_files),
231
+ }
232
+
233
+
234
+ def _baseline_digest(payload: dict[str, Any]) -> str:
235
+ encoded = json.dumps(
236
+ payload,
237
+ ensure_ascii=False,
238
+ separators=(",", ":"),
239
+ sort_keys=True,
240
+ ).encode("utf-8")
241
+ return sha256(encoded).hexdigest()
242
+
243
+
244
+ def landing_cleanup_policy_digest(profile: WorktreeProfile) -> str:
245
+ """Bind one attempt to the exact ordered policy that nominated its files."""
246
+ return _baseline_digest({
247
+ "scratchPatterns": list(profile.scratch_patterns),
248
+ "landingGeneratedArtifactPatterns": list(
249
+ profile.landing_generated_artifact_patterns
250
+ ),
251
+ })
252
+
253
+
254
+ def capture_artifact_baseline(
255
+ worktree: Path,
256
+ *,
257
+ reject_ignored_patterns: tuple[str, ...] = (),
258
+ ) -> ArtifactBaseline:
259
+ worktree = worktree.resolve()
260
+ metadata = worktree.stat()
261
+ branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
262
+ setup_head = run(["git", "rev-parse", "HEAD"], cwd=worktree).stdout.strip()
263
+ ignored = ignored_file_inventory(worktree)
264
+ untracked = untracked_file_inventory(worktree)
265
+ blocked = tuple(sorted(
266
+ path
267
+ for path in ignored
268
+ if any(path_glob_matches(path, pattern) for pattern in reject_ignored_patterns)
269
+ ))
270
+ if blocked:
271
+ raise BaselineBackfillDeferred(
272
+ "landing-start generated paths are consumer-owned and protected: "
273
+ + ", ".join(blocked)
274
+ )
275
+ payload = _baseline_payload(
276
+ worktree=worktree,
277
+ branch=branch,
278
+ root_device=metadata.st_dev,
279
+ root_inode=metadata.st_ino,
280
+ setup_head=setup_head,
281
+ initial_ignored_files=ignored,
282
+ initial_untracked_files=untracked,
283
+ )
284
+ digest = _baseline_digest(payload)
285
+ path = artifact_baseline_path(worktree)
286
+ durable_atomic_json(
287
+ path,
288
+ {**payload, "sha256": digest},
289
+ label="write artifact provenance baseline",
290
+ )
291
+ return ArtifactBaseline(
292
+ worktree,
293
+ branch,
294
+ metadata.st_dev,
295
+ metadata.st_ino,
296
+ setup_head,
297
+ ignored,
298
+ untracked,
299
+ digest,
300
+ )
301
+
302
+
303
+ def load_artifact_baseline(worktree: Path) -> ArtifactBaseline:
304
+ worktree = worktree.resolve()
305
+ path = artifact_baseline_path(worktree)
306
+ try:
307
+ if path.is_symlink() or not path.is_file():
308
+ raise LifecycleError("artifact provenance baseline is missing or not a regular file")
309
+ document = json.loads(path.read_text(encoding="utf-8"))
310
+ payload = {
311
+ key: document[key]
312
+ for key in (
313
+ "contractVersion",
314
+ "worktree",
315
+ "branch",
316
+ "rootDevice",
317
+ "rootInode",
318
+ "setupHead",
319
+ "initialIgnoredFiles",
320
+ "initialUntrackedFiles",
321
+ )
322
+ }
323
+ digest = document["sha256"]
324
+ except LifecycleError:
325
+ raise
326
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
327
+ raise LifecycleError(f"artifact provenance baseline is incoherent: {error}") from error
328
+ ignored = payload["initialIgnoredFiles"]
329
+ untracked = payload["initialUntrackedFiles"]
330
+ if (
331
+ payload["contractVersion"] != 2
332
+ or not isinstance(payload["worktree"], str)
333
+ or not isinstance(payload["branch"], str)
334
+ or not payload["branch"]
335
+ or type(payload["rootDevice"]) is not int
336
+ or type(payload["rootInode"]) is not int
337
+ or not isinstance(payload["setupHead"], str)
338
+ or re.fullmatch(r"[0-9a-f]{40,64}", payload["setupHead"]) is None
339
+ or not isinstance(ignored, list)
340
+ or not all(
341
+ isinstance(path_value, str)
342
+ and path_value
343
+ and not PurePosixPath(path_value).is_absolute()
344
+ and ".." not in PurePosixPath(path_value).parts
345
+ for path_value in ignored
346
+ )
347
+ or ignored != sorted(set(ignored))
348
+ or not isinstance(untracked, list)
349
+ or not all(
350
+ isinstance(path_value, str)
351
+ and path_value
352
+ and not PurePosixPath(path_value).is_absolute()
353
+ and ".." not in PurePosixPath(path_value).parts
354
+ for path_value in untracked
355
+ )
356
+ or untracked != sorted(set(untracked))
357
+ or not set(ignored).issubset(untracked)
358
+ or not isinstance(digest, str)
359
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
360
+ or digest != _baseline_digest(payload)
361
+ ):
362
+ raise LifecycleError("artifact provenance baseline is incoherent")
363
+ try:
364
+ metadata = worktree.stat()
365
+ branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
366
+ except (OSError, LifecycleError) as error:
367
+ raise LifecycleError(
368
+ f"artifact provenance baseline binding cannot be verified: {error}"
369
+ ) from error
370
+ if (
371
+ payload["worktree"] != str(worktree)
372
+ or payload["branch"] != branch
373
+ or (payload["rootDevice"], payload["rootInode"])
374
+ != (metadata.st_dev, metadata.st_ino)
375
+ ):
376
+ raise LifecycleError("artifact provenance baseline binding does not match worktree")
377
+ return ArtifactBaseline(
378
+ worktree,
379
+ branch,
380
+ metadata.st_dev,
381
+ metadata.st_ino,
382
+ payload["setupHead"],
383
+ tuple(ignored),
384
+ tuple(untracked),
385
+ digest,
386
+ )
387
+
388
+
389
+ def ensure_artifact_baseline(
390
+ worktree: Path,
391
+ *,
392
+ reject_ignored_patterns: tuple[str, ...] = (),
393
+ ) -> ArtifactBaseline:
394
+ """Load provenance or conservatively backfill one exact clean legacy worktree."""
395
+ path = artifact_baseline_path(worktree)
396
+ if os.path.lexists(path):
397
+ return load_artifact_baseline(worktree)
398
+ attempt_path = path.with_name(LANDING_ATTEMPT_FILE)
399
+ if os.path.lexists(attempt_path):
400
+ raise LifecycleError(
401
+ "artifact provenance baseline is missing while a landing attempt exists"
402
+ )
403
+ absolute = worktree.absolute()
404
+ try:
405
+ metadata = os.lstat(absolute)
406
+ except OSError as error:
407
+ raise LifecycleError(
408
+ f"legacy artifact baseline root cannot be inspected: {error}"
409
+ ) from error
410
+ if not stat.S_ISDIR(metadata.st_mode) or absolute != worktree.resolve():
411
+ raise LifecycleError("legacy artifact baseline root is not an exact nofollow directory")
412
+ main = main_worktree(worktree)
413
+ if worktree.resolve() not in registered_worktrees(main):
414
+ raise LifecycleError("legacy artifact baseline requires an exact registered worktree")
415
+ branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
416
+ if not branch:
417
+ raise LifecycleError("legacy artifact baseline requires an attached branch")
418
+ for command in (
419
+ ["git", "diff", "--quiet"],
420
+ ["git", "diff", "--cached", "--quiet"],
421
+ ):
422
+ if run(command, cwd=worktree, check=False).returncode != 0:
423
+ raise BaselineBackfillDeferred(
424
+ "legacy artifact baseline requires a clean tracked worktree and index"
425
+ )
426
+ return capture_artifact_baseline(
427
+ worktree,
428
+ reject_ignored_patterns=reject_ignored_patterns,
429
+ )
430
+
431
+
432
+ def verified_landing_scratch_files(
433
+ profile: WorktreeProfile,
434
+ worktree: Path,
435
+ *,
436
+ expected_baseline_digest: str | None = None,
437
+ landing_start_files: tuple[str, ...] = (),
438
+ ) -> tuple[str, ...]:
439
+ return tuple(
440
+ item["path"]
441
+ for item in verified_landing_scratch_evidence(
442
+ profile,
443
+ worktree,
444
+ expected_baseline_digest=expected_baseline_digest,
445
+ landing_start_files=landing_start_files,
446
+ )
447
+ )
448
+
449
+
450
+ def landing_start_artifact_inventory(
451
+ profile: WorktreeProfile,
452
+ worktree: Path,
453
+ ) -> dict[str, Any]:
454
+ """Persist/reuse the generated-path inventory preceding the landing build."""
455
+ baseline = ensure_artifact_baseline(
456
+ worktree,
457
+ reject_ignored_patterns=profile.landing_generated_artifact_patterns,
458
+ )
459
+ path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
460
+ if path.exists():
461
+ try:
462
+ document = json.loads(path.read_text(encoding="utf-8"))
463
+ payload = {
464
+ key: document[key]
465
+ for key in (
466
+ "contractVersion", "worktree", "branch", "rootDevice",
467
+ "rootInode", "baselineDigest", "generatedFiles",
468
+ "generatedEvidence", "state", "authorizedEvidence",
469
+ "pushSucceeded", "policyDigest",
470
+ )
471
+ }
472
+ digest = document["sha256"]
473
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
474
+ raise LifecycleError(
475
+ f"landing-attempt provenance is incoherent: {error}"
476
+ ) from error
477
+ if payload["policyDigest"] != landing_cleanup_policy_digest(profile):
478
+ raise LifecycleError(
479
+ "landing cleanup policy changed after attempt start; "
480
+ "abandon the unfinished attempt before retrying"
481
+ )
482
+ if (
483
+ payload["contractVersion"] != 2
484
+ or payload["worktree"] != str(worktree.resolve())
485
+ or payload["branch"] != baseline.branch
486
+ or (payload["rootDevice"], payload["rootInode"])
487
+ != (baseline.root_device, baseline.root_inode)
488
+ or payload["baselineDigest"] != baseline.digest
489
+ or not isinstance(payload["generatedFiles"], list)
490
+ or payload["generatedFiles"] != sorted(set(payload["generatedFiles"]))
491
+ or not isinstance(payload["generatedEvidence"], list)
492
+ or payload["state"] not in {"started", "frozen"}
493
+ or not isinstance(payload["authorizedEvidence"], list)
494
+ or type(payload["pushSucceeded"]) is not bool
495
+ or digest != _baseline_digest(payload)
496
+ ):
497
+ raise LifecycleError("landing-attempt provenance is incoherent")
498
+ return {
499
+ "baselineDigest": payload["baselineDigest"],
500
+ "generatedFiles": payload["generatedFiles"],
501
+ "generatedEvidence": payload["generatedEvidence"],
502
+ "state": payload["state"],
503
+ "authorizedEvidence": payload["authorizedEvidence"],
504
+ "pushSucceeded": payload["pushSucceeded"],
505
+ "policyDigest": payload["policyDigest"],
506
+ "newAttempt": False,
507
+ }
508
+ current = set(ignored_file_inventory(worktree))
509
+ generated = tuple(sorted(
510
+ path for path in current
511
+ if any(
512
+ path_glob_matches(path, pattern)
513
+ for pattern in profile.landing_generated_artifact_patterns
514
+ )
515
+ ))
516
+ if generated:
517
+ raise LifecycleError(
518
+ "landing-start generated paths are consumer-owned and protected: "
519
+ + ", ".join(generated)
520
+ )
521
+ result = {
522
+ "baselineDigest": baseline.digest,
523
+ "generatedFiles": list(generated),
524
+ "generatedEvidence": [],
525
+ "state": "started",
526
+ "authorizedEvidence": [],
527
+ "pushSucceeded": False,
528
+ "policyDigest": landing_cleanup_policy_digest(profile),
529
+ }
530
+ payload = {
531
+ "contractVersion": 2,
532
+ "worktree": str(worktree.resolve()),
533
+ "branch": baseline.branch,
534
+ "rootDevice": baseline.root_device,
535
+ "rootInode": baseline.root_inode,
536
+ **result,
537
+ }
538
+ digest = _baseline_digest(payload)
539
+ durable_atomic_json(
540
+ path,
541
+ {**payload, "sha256": digest},
542
+ label="persist landing-attempt provenance",
543
+ )
544
+ return {**result, "newAttempt": True}
545
+
546
+
547
+ def verified_landing_scratch_evidence(
548
+ profile: WorktreeProfile,
549
+ worktree: Path,
550
+ *,
551
+ expected_baseline_digest: str | None = None,
552
+ landing_start_files: tuple[str, ...] = (),
553
+ ) -> tuple[dict[str, Any], ...]:
554
+ """Return frozen regular-file identities for the authorized generator delta."""
555
+ baseline = load_artifact_baseline(worktree)
556
+ if (
557
+ expected_baseline_digest is not None
558
+ and baseline.digest != expected_baseline_digest
559
+ ):
560
+ raise LifecycleError("artifact provenance baseline changed during landing")
561
+ current = set(ignored_file_inventory(worktree))
562
+ initial = current.intersection(baseline.initial_ignored_files)
563
+ initial_generated = sorted(
564
+ path for path in initial
565
+ if any(
566
+ path_glob_matches(path, pattern)
567
+ for pattern in profile.landing_generated_artifact_patterns
568
+ )
569
+ )
570
+ if initial_generated:
571
+ raise LifecycleError(
572
+ "artifact provenance baseline protects initial generated paths: "
573
+ + ", ".join(initial_generated)
574
+ )
575
+ if landing_start_files:
576
+ raise LifecycleError(
577
+ "landing-start generated paths are consumer-owned and protected: "
578
+ + ", ".join(sorted(landing_start_files))
579
+ )
580
+ candidates = tuple(sorted(
581
+ path
582
+ for path in current.difference(baseline.initial_ignored_files)
583
+ if path not in landing_start_files
584
+ if any(
585
+ path_glob_matches(path, pattern)
586
+ for pattern in profile.landing_generated_artifact_patterns
587
+ )
588
+ ))
589
+ if not candidates:
590
+ return ()
591
+ with verified_worktree_root(
592
+ worktree,
593
+ baseline.root_device,
594
+ baseline.root_inode,
595
+ ) as descriptor:
596
+ return tuple(
597
+ contained_regular_identity(descriptor, relative)
598
+ for relative in candidates
599
+ )
600
+
601
+
602
+ def freeze_landing_artifact_evidence(
603
+ profile: WorktreeProfile,
604
+ worktree: Path,
605
+ *,
606
+ push_succeeded: bool,
607
+ ) -> tuple[dict[str, Any], ...]:
608
+ """Freeze or revalidate the exact output of one generator-capable push."""
609
+ attempt = landing_start_artifact_inventory(profile, worktree)
610
+ current = verified_landing_scratch_evidence(
611
+ profile,
612
+ worktree,
613
+ expected_baseline_digest=attempt["baselineDigest"],
614
+ landing_start_files=tuple(attempt["generatedFiles"]),
615
+ )
616
+ frozen = tuple(attempt["authorizedEvidence"])
617
+ if attempt["state"] == "frozen" and frozen != current:
618
+ raise LifecycleError(
619
+ "landing-generated evidence changed after it was frozen"
620
+ )
621
+ if attempt["state"] == "frozen":
622
+ return frozen
623
+ path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
624
+ document = json.loads(path.read_text(encoding="utf-8"))
625
+ payload = {
626
+ key: document[key]
627
+ for key in (
628
+ "contractVersion", "worktree", "branch", "rootDevice",
629
+ "rootInode", "baselineDigest", "generatedFiles",
630
+ "generatedEvidence", "policyDigest",
631
+ )
632
+ }
633
+ payload.update({
634
+ "state": "frozen",
635
+ "authorizedEvidence": list(current),
636
+ "pushSucceeded": push_succeeded,
637
+ })
638
+ digest = _baseline_digest(payload)
639
+ durable_atomic_json(
640
+ path,
641
+ {**payload, "sha256": digest},
642
+ label="freeze landing-generated evidence",
643
+ )
644
+ return current
645
+
646
+
647
+ def reopen_frozen_landing_attempt(
648
+ profile: WorktreeProfile,
649
+ worktree: Path,
650
+ ) -> tuple[dict[str, Any], ...]:
651
+ """Validate a failed push boundary before permitting its generator to retry."""
652
+ frozen = freeze_landing_artifact_evidence(
653
+ profile, worktree, push_succeeded=False
654
+ )
655
+ path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
656
+ document = json.loads(path.read_text(encoding="utf-8"))
657
+ payload = {
658
+ key: document[key]
659
+ for key in (
660
+ "contractVersion", "worktree", "branch", "rootDevice",
661
+ "rootInode", "baselineDigest", "generatedFiles",
662
+ "generatedEvidence", "policyDigest",
663
+ )
664
+ }
665
+ payload.update({
666
+ "state": "started",
667
+ "authorizedEvidence": [],
668
+ "pushSucceeded": False,
669
+ })
670
+ digest = _baseline_digest(payload)
671
+ durable_atomic_json(
672
+ path,
673
+ {**payload, "sha256": digest},
674
+ label="reopen validated landing attempt",
675
+ )
676
+ return frozen
677
+
678
+
679
+ def abandon_unfinished_landing_attempt(
680
+ worktree: Path,
681
+ ) -> Path:
682
+ """Archive an ambiguous started attempt without claiming or deleting files."""
683
+ path = artifact_baseline_path(worktree).with_name(LANDING_ATTEMPT_FILE)
684
+ if not os.path.lexists(path):
685
+ raise LifecycleError("no pre-existing unfinished landing attempt to abandon")
686
+ try:
687
+ if path.is_symlink() or not path.is_file():
688
+ raise LifecycleError("landing-attempt provenance is not a regular file")
689
+ document = json.loads(path.read_text(encoding="utf-8"))
690
+ contract_version = document["contractVersion"]
691
+ keys = [
692
+ "contractVersion", "worktree", "branch", "rootDevice",
693
+ "rootInode", "baselineDigest", "generatedFiles",
694
+ "generatedEvidence", "state", "authorizedEvidence",
695
+ "pushSucceeded",
696
+ ]
697
+ if contract_version == 2:
698
+ keys.append("policyDigest")
699
+ payload = {
700
+ key: document[key]
701
+ for key in keys
702
+ }
703
+ digest = document["sha256"]
704
+ metadata = os.lstat(worktree)
705
+ branch = run(["git", "branch", "--show-current"], cwd=worktree).stdout.strip()
706
+ except LifecycleError:
707
+ raise
708
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
709
+ raise LifecycleError(f"landing-attempt provenance is incoherent: {error}") from error
710
+ if (
711
+ payload["contractVersion"] not in {1, 2}
712
+ or payload["worktree"] != str(worktree.resolve())
713
+ or payload["branch"] != branch
714
+ or not stat.S_ISDIR(metadata.st_mode)
715
+ or (payload["rootDevice"], payload["rootInode"])
716
+ != (metadata.st_dev, metadata.st_ino)
717
+ or payload["state"] not in {"started", "frozen"}
718
+ or digest != _baseline_digest(payload)
719
+ ):
720
+ raise LifecycleError("landing-attempt provenance is incoherent")
721
+ archive = path.with_name(
722
+ f"{path.stem}.abandoned-{int(time() * 1_000_000)}.json"
723
+ )
724
+ durable_replace(path, archive, label="archive landing attempt")
725
+ return archive
726
+
727
+
73
728
  def collect_facts(cwd: Path) -> RepoFacts:
74
729
  root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
75
730
  main = main_worktree(root)
@@ -142,8 +797,10 @@ def cleanup_assessment(
142
797
  target: Path,
143
798
  merge_target: str | None = None,
144
799
  pr_state: str = "none",
800
+ verified_scratch_files: tuple[str, ...] = (),
801
+ verified_scratch_evidence: tuple[dict[str, Any], ...] = (),
145
802
  ) -> CleanupAssessment:
146
- return classify_cleanup(
803
+ assessment = classify_cleanup(
147
804
  profile,
148
805
  collect_cleanup_facts(
149
806
  main,
@@ -151,7 +808,20 @@ def cleanup_assessment(
151
808
  merge_target=merge_target,
152
809
  pr_state=pr_state,
153
810
  ),
811
+ verified_scratch_files=verified_scratch_files,
154
812
  )
813
+ try:
814
+ return bind_cleanup_scratch_evidence(
815
+ profile,
816
+ assessment,
817
+ verified_scratch_evidence,
818
+ require_generator_evidence=True,
819
+ )
820
+ except LifecycleError as error:
821
+ return replace(
822
+ assessment,
823
+ reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
824
+ )
155
825
 
156
826
 
157
827
  def collect_cleanup_facts(
@@ -225,6 +895,8 @@ def collect_cleanup_facts(
225
895
  def classify_cleanup(
226
896
  profile: WorktreeProfile,
227
897
  facts: CleanupFacts,
898
+ *,
899
+ verified_scratch_files: tuple[str, ...] = (),
228
900
  ) -> CleanupAssessment:
229
901
  reasons = []
230
902
  if not facts.registered:
@@ -233,9 +905,19 @@ def classify_cleanup(
233
905
  reasons.append("detached or unreadable branch")
234
906
  if facts.branch in profile.protected_branches or facts.is_main:
235
907
  reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
908
+ verified = set(verified_scratch_files)
909
+ missing_verified = sorted(verified.difference(facts.untracked_files))
910
+ if missing_verified:
911
+ reasons.append(
912
+ "verified scratch evidence no longer matches inventory: "
913
+ + ", ".join(missing_verified)
914
+ )
236
915
  scratch = sorted(
237
916
  path for path in facts.untracked_files
238
- if any(fnmatchcase(path, pattern) for pattern in profile.scratch_patterns)
917
+ if (
918
+ path in verified
919
+ or any(path_glob_matches(path, pattern) for pattern in profile.scratch_patterns)
920
+ )
239
921
  )
240
922
  non_scratch = sorted(set(facts.untracked_files).difference(scratch))
241
923
  if facts.tracked_files:
@@ -263,6 +945,374 @@ def classify_cleanup(
263
945
  )
264
946
 
265
947
 
948
+ @contextmanager
949
+ def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
950
+ """Open the assessed worktree root without following a replacement symlink."""
951
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
952
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
953
+ descriptor = None
954
+ try:
955
+ descriptor = os.open(root, directory_flags | no_follow)
956
+ metadata = os.fstat(descriptor)
957
+ if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
958
+ raise LifecycleError("worktree root changed before removal")
959
+ yield descriptor
960
+ except OSError as error:
961
+ raise LifecycleError("worktree root changed before removal") from error
962
+ finally:
963
+ if descriptor is not None:
964
+ os.close(descriptor)
965
+
966
+
967
+ def remove_contained_regular(
968
+ root_descriptor: int,
969
+ relative: str,
970
+ expected_identity: dict[str, Any] | None = None,
971
+ ) -> None:
972
+ """Delete one exact assessed regular file without following path symlinks."""
973
+ path = PurePosixPath(relative)
974
+ if path.is_absolute() or not path.parts or any(
975
+ part in {"", ".", ".."} for part in path.parts
976
+ ):
977
+ raise LifecycleError(f"unsafe scratch path: {relative}")
978
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
979
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
980
+ descriptors = []
981
+ try:
982
+ current = root_descriptor
983
+ for component in path.parts[:-1]:
984
+ current = os.open(
985
+ component,
986
+ directory_flags | no_follow,
987
+ dir_fd=current,
988
+ )
989
+ descriptors.append(current)
990
+ initial_metadata = os.stat(
991
+ path.name,
992
+ dir_fd=current,
993
+ follow_symlinks=False,
994
+ )
995
+ if not stat.S_ISREG(initial_metadata.st_mode):
996
+ raise LifecycleError(f"scratch path is not a regular file: {relative}")
997
+ file_descriptor = os.open(
998
+ path.name,
999
+ os.O_RDONLY | no_follow,
1000
+ dir_fd=current,
1001
+ )
1002
+ try:
1003
+ metadata = os.fstat(file_descriptor)
1004
+ digest = sha256()
1005
+ while chunk := os.read(file_descriptor, 128 * 1024):
1006
+ digest.update(chunk)
1007
+ finally:
1008
+ os.close(file_descriptor)
1009
+ if (
1010
+ not stat.S_ISREG(metadata.st_mode)
1011
+ or (metadata.st_dev, metadata.st_ino)
1012
+ != (initial_metadata.st_dev, initial_metadata.st_ino)
1013
+ ):
1014
+ raise LifecycleError(f"scratch path changed before removal: {relative}")
1015
+ identity = {
1016
+ "path": relative,
1017
+ "device": metadata.st_dev,
1018
+ "inode": metadata.st_ino,
1019
+ "size": metadata.st_size,
1020
+ "sha256": digest.hexdigest(),
1021
+ }
1022
+ if expected_identity is not None and identity != expected_identity:
1023
+ raise LifecycleError(f"scratch path identity changed: {relative}")
1024
+ latest = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1025
+ if (latest.st_dev, latest.st_ino) != (metadata.st_dev, metadata.st_ino):
1026
+ raise LifecycleError(f"scratch path changed before removal: {relative}")
1027
+ os.unlink(path.name, dir_fd=current)
1028
+ except OSError as error:
1029
+ raise LifecycleError(f"scratch path changed before removal: {relative}") from error
1030
+ finally:
1031
+ for descriptor in reversed(descriptors):
1032
+ os.close(descriptor)
1033
+
1034
+
1035
+ def remove_authorized_scratch(
1036
+ profile: WorktreeProfile,
1037
+ root_descriptor: int,
1038
+ scratch_files: tuple[str, ...] | list[str],
1039
+ verified_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
1040
+ ) -> None:
1041
+ """Remove profile scratch or exact generator evidence without weakening overlaps."""
1042
+ evidence_by_path: dict[str, dict[str, Any]] = {}
1043
+ for item in verified_evidence:
1044
+ relative = item.get("path")
1045
+ if not isinstance(relative, str) or relative in evidence_by_path:
1046
+ raise LifecycleError("scratch evidence is incoherent")
1047
+ evidence_by_path[relative] = item
1048
+ unexpected = sorted(set(evidence_by_path).difference(scratch_files))
1049
+ if unexpected:
1050
+ raise LifecycleError(
1051
+ "scratch evidence is outside the assessed inventory: "
1052
+ + ", ".join(unexpected)
1053
+ )
1054
+ for relative in scratch_files:
1055
+ generated = any(
1056
+ path_glob_matches(relative, pattern)
1057
+ for pattern in profile.landing_generated_artifact_patterns
1058
+ )
1059
+ profile_authorized = any(
1060
+ path_glob_matches(relative, pattern)
1061
+ for pattern in profile.scratch_patterns
1062
+ )
1063
+ expected = evidence_by_path.get(relative)
1064
+ if not generated and not profile_authorized:
1065
+ raise LifecycleError(
1066
+ f"canonical cleanup policy does not authorize scratch: {relative}"
1067
+ )
1068
+ if generated and expected is None:
1069
+ raise LifecycleError(
1070
+ f"landing-generated scratch evidence is missing: {relative}"
1071
+ )
1072
+ if profile_authorized and expected is None:
1073
+ raise LifecycleError(f"profile scratch evidence is missing: {relative}")
1074
+ if not profile_authorized and expected is None:
1075
+ raise LifecycleError(f"verified scratch evidence is missing: {relative}")
1076
+ for relative in scratch_files:
1077
+ expected = evidence_by_path[relative]
1078
+ remove_contained_regular(
1079
+ root_descriptor,
1080
+ relative,
1081
+ expected_identity=expected,
1082
+ )
1083
+
1084
+
1085
+ def bind_cleanup_scratch_evidence(
1086
+ profile: WorktreeProfile,
1087
+ assessment: CleanupAssessment,
1088
+ verified_generator_evidence: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
1089
+ *,
1090
+ require_generator_evidence: bool = False,
1091
+ ) -> CleanupAssessment:
1092
+ """Freeze identity for every assessed scratch path at its authority boundary."""
1093
+ evidence_by_path = {
1094
+ item.get("path"): item
1095
+ for item in verified_generator_evidence
1096
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1097
+ }
1098
+ if len(evidence_by_path) != len(verified_generator_evidence):
1099
+ raise LifecycleError("scratch evidence is incoherent")
1100
+ unauthorized_evidence = sorted(
1101
+ relative
1102
+ for relative in evidence_by_path
1103
+ if not any(
1104
+ path_glob_matches(relative, pattern)
1105
+ for pattern in profile.landing_generated_artifact_patterns
1106
+ )
1107
+ )
1108
+ if unauthorized_evidence:
1109
+ raise LifecycleError(
1110
+ "generator evidence is outside canonical landing policy: "
1111
+ + ", ".join(unauthorized_evidence)
1112
+ )
1113
+ scratch = set(assessment.scratch_files)
1114
+ if not set(evidence_by_path).issubset(scratch):
1115
+ raise LifecycleError("scratch evidence is outside the assessed inventory")
1116
+ profile_only = [
1117
+ relative
1118
+ for relative in assessment.scratch_files
1119
+ if not any(
1120
+ path_glob_matches(relative, pattern)
1121
+ for pattern in profile.landing_generated_artifact_patterns
1122
+ )
1123
+ ]
1124
+ missing_generator = [
1125
+ relative
1126
+ for relative in assessment.scratch_files
1127
+ if any(
1128
+ path_glob_matches(relative, pattern)
1129
+ for pattern in profile.landing_generated_artifact_patterns
1130
+ )
1131
+ and relative not in evidence_by_path
1132
+ ]
1133
+ if missing_generator and require_generator_evidence:
1134
+ raise LifecycleError(
1135
+ "landing-generated scratch evidence is missing: "
1136
+ + ", ".join(missing_generator)
1137
+ )
1138
+ with verified_worktree_root(
1139
+ assessment.worktree,
1140
+ assessment.root_device,
1141
+ assessment.root_inode,
1142
+ ) as descriptor:
1143
+ for relative in profile_only:
1144
+ evidence_by_path[relative] = contained_regular_identity(
1145
+ descriptor, relative
1146
+ )
1147
+ return replace(
1148
+ assessment,
1149
+ scratch_evidence=tuple(
1150
+ evidence_by_path[relative]
1151
+ for relative in assessment.scratch_files
1152
+ if relative in evidence_by_path
1153
+ ),
1154
+ )
1155
+
1156
+
1157
+ def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str, Any]:
1158
+ """Read one exact regular file identity without following path symlinks."""
1159
+ path = PurePosixPath(relative)
1160
+ if path.is_absolute() or not path.parts or any(
1161
+ part in {"", ".", ".."} for part in path.parts
1162
+ ):
1163
+ raise LifecycleError(f"unsafe scratch path: {relative}")
1164
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1165
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
1166
+ descriptors = []
1167
+ try:
1168
+ current = root_descriptor
1169
+ for component in path.parts[:-1]:
1170
+ current = os.open(
1171
+ component,
1172
+ directory_flags | no_follow,
1173
+ dir_fd=current,
1174
+ )
1175
+ descriptors.append(current)
1176
+ initial = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1177
+ if not stat.S_ISREG(initial.st_mode):
1178
+ raise LifecycleError(
1179
+ f"scratch path is not a regular file: {relative}"
1180
+ )
1181
+ file_descriptor = os.open(
1182
+ path.name,
1183
+ os.O_RDONLY | no_follow,
1184
+ dir_fd=current,
1185
+ )
1186
+ try:
1187
+ metadata = os.fstat(file_descriptor)
1188
+ if (
1189
+ not stat.S_ISREG(metadata.st_mode)
1190
+ or (metadata.st_dev, metadata.st_ino)
1191
+ != (initial.st_dev, initial.st_ino)
1192
+ ):
1193
+ raise LifecycleError(
1194
+ f"scratch path changed before inspection: {relative}"
1195
+ )
1196
+ digest = sha256()
1197
+ while chunk := os.read(file_descriptor, 128 * 1024):
1198
+ digest.update(chunk)
1199
+ finally:
1200
+ os.close(file_descriptor)
1201
+ return {
1202
+ "path": relative,
1203
+ "device": metadata.st_dev,
1204
+ "inode": metadata.st_ino,
1205
+ "size": metadata.st_size,
1206
+ "sha256": digest.hexdigest(),
1207
+ }
1208
+ except OSError as error:
1209
+ raise LifecycleError(f"scratch path changed before inspection: {relative}") from error
1210
+ finally:
1211
+ for descriptor in reversed(descriptors):
1212
+ os.close(descriptor)
1213
+
1214
+
1215
+ def contained_untracked_identity(
1216
+ root_descriptor: int,
1217
+ relative: str,
1218
+ ) -> dict[str, Any]:
1219
+ """Read exact regular/symlink identity without following any symlink."""
1220
+ path = PurePosixPath(relative)
1221
+ if path.is_absolute() or not path.parts or any(
1222
+ part in {"", ".", ".."} for part in path.parts
1223
+ ):
1224
+ raise LifecycleError(f"unsafe recovery path: {relative}")
1225
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1226
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
1227
+ descriptors = []
1228
+ try:
1229
+ current = root_descriptor
1230
+ for component in path.parts[:-1]:
1231
+ current = os.open(
1232
+ component,
1233
+ directory_flags | no_follow,
1234
+ dir_fd=current,
1235
+ )
1236
+ descriptors.append(current)
1237
+ metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
1238
+ common = {
1239
+ "path": relative,
1240
+ "device": metadata.st_dev,
1241
+ "inode": metadata.st_ino,
1242
+ "size": metadata.st_size,
1243
+ }
1244
+ if stat.S_ISLNK(metadata.st_mode):
1245
+ target = os.readlink(path.name, dir_fd=current).encode(
1246
+ "utf-8", errors="surrogateescape"
1247
+ )
1248
+ return {**common, "kind": "symlink", "sha256": sha256(target).hexdigest()}
1249
+ if not stat.S_ISREG(metadata.st_mode):
1250
+ raise LifecycleError(f"unsupported recovery path type: {relative}")
1251
+ file_descriptor = os.open(
1252
+ path.name,
1253
+ os.O_RDONLY | no_follow,
1254
+ dir_fd=current,
1255
+ )
1256
+ try:
1257
+ opened = os.fstat(file_descriptor)
1258
+ if (opened.st_dev, opened.st_ino) != (
1259
+ metadata.st_dev, metadata.st_ino
1260
+ ):
1261
+ raise LifecycleError(
1262
+ f"recovery path changed before inspection: {relative}"
1263
+ )
1264
+ digest = sha256()
1265
+ while chunk := os.read(file_descriptor, 128 * 1024):
1266
+ digest.update(chunk)
1267
+ finally:
1268
+ os.close(file_descriptor)
1269
+ return {**common, "kind": "regular", "sha256": digest.hexdigest()}
1270
+ except OSError as error:
1271
+ raise LifecycleError(
1272
+ f"recovery path changed before inspection: {relative}"
1273
+ ) from error
1274
+ finally:
1275
+ for descriptor in reversed(descriptors):
1276
+ os.close(descriptor)
1277
+
1278
+
1279
+ def remove_contained_untracked(
1280
+ root_descriptor: int,
1281
+ expected_identity: dict[str, Any],
1282
+ ) -> None:
1283
+ """Unlink one frozen regular file or symlink if its identity still matches."""
1284
+ relative = expected_identity.get("path")
1285
+ if not isinstance(relative, str):
1286
+ raise LifecycleError("recovery path evidence is incoherent")
1287
+ current = contained_untracked_identity(root_descriptor, relative)
1288
+ if current != expected_identity:
1289
+ raise LifecycleError(f"recovery path identity changed: {relative}")
1290
+ path = PurePosixPath(relative)
1291
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1292
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
1293
+ descriptors = []
1294
+ try:
1295
+ parent = root_descriptor
1296
+ for component in path.parts[:-1]:
1297
+ parent = os.open(
1298
+ component,
1299
+ directory_flags | no_follow,
1300
+ dir_fd=parent,
1301
+ )
1302
+ descriptors.append(parent)
1303
+ latest = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
1304
+ if (latest.st_dev, latest.st_ino) != (
1305
+ expected_identity["device"], expected_identity["inode"]
1306
+ ):
1307
+ raise LifecycleError(f"recovery path changed before removal: {relative}")
1308
+ os.unlink(path.name, dir_fd=parent)
1309
+ except OSError as error:
1310
+ raise LifecycleError(f"recovery path changed before removal: {relative}") from error
1311
+ finally:
1312
+ for descriptor in reversed(descriptors):
1313
+ os.close(descriptor)
1314
+
1315
+
266
1316
  @dataclass(frozen=True)
267
1317
  class SweepRow:
268
1318
  kind: str