@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.
@@ -19,6 +19,15 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
19
19
  - `riskyCommandPatterns`: commands that must target the active linked worktree.
20
20
  - `scratchPatterns`: consumer-owned glob patterns for untracked disposable
21
21
  planning artefacts. No filename is assumed by Core.
22
+ - `wrapup.landingGeneratedArtifactPatterns`: an explicitly reviewed
23
+ consumer-owned allowlist for outputs created by the landing commands. It is
24
+ deletion authority, so setup never derives it from `.gitignore` alone and
25
+ update never installs a universal default.
26
+
27
+ Profile globs use repository-relative POSIX semantics. `*` stays inside one
28
+ path segment; `**` crosses `/`, and leading `**/` also matches the repository
29
+ root. For example, `**/__pycache__/**` matches both root and nested caches,
30
+ while `dist-kit/*` does not match `dist-kit/a/b`.
22
31
 
23
32
  Unknown or malformed events fail open without changing repository state.
24
33
  Security-sensitive, profile-matched edits and commands fail closed only when
@@ -49,11 +58,110 @@ same removable inventory, deletes only the exact contained regular scratch
49
58
  files from that inventory, and uses ordinary `git worktree remove`. It never
50
59
  bypasses Git's final concurrent-change check with force removal.
51
60
 
61
+ The generic setup route atomically records its ignored and complete
62
+ untracked-file inventories in the linked worktree's Git metadata after its
63
+ configured setup steps. The record is bound to the worktree path, branch, root
64
+ device/inode, and setup HEAD, and carries a canonical digest. Existing
65
+ worktrees can receive a conservative baseline only when they are the exact
66
+ registered no-follow directory on an attached branch with a clean tracked
67
+ worktree and index. Setup reuse defers that backfill, without blocking the
68
+ existing worktree, while tracked work is dirty or landing-generated blockers
69
+ are present. Landing classifies those blockers before writing any baseline;
70
+ after they are moved, every remaining current ignored and untracked path is
71
+ recorded as pre-existing and therefore protected. A corrupt baseline or an
72
+ active landing attempt is never overwritten. The claim-bound session route
73
+ below captures its stricter baseline before project setup so a failed setup has
74
+ an exact recovery boundary.
75
+
76
+ Before merge, the committed worktree profile may only nominate exact,
77
+ identity-bound landing candidates; it authorizes no deletion. After merge and
78
+ `fetch origin/main`, cleanup reloads the profile directly from canonical
79
+ `origin/main`, requires its scratch and generator policies to equal the
80
+ worktree candidate and the policy digest frozen at attempt start, and only then
81
+ authorizes mutation. Every supplied generator-evidence path is independently
82
+ checked against that canonical generator policy. A missing policy is distinct
83
+ from an explicit empty policy. An unmerged or transient branch policy therefore
84
+ cannot grant itself broader cleanup authority.
85
+
86
+ The landing adapter may carry exact scratch evidence only for current ignored
87
+ files that match the consumer-owned
88
+ `wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
89
+ creation baseline. Missing, changed, or incoherent provenance stops landing
90
+ cleanup. Initial/profile-matched files, unmatched files, symlinks, and writes
91
+ after the landing evidence snapshot remain cleanup stops; deletion still uses
92
+ the same descriptor-bound regular-file primitive and a second inventory check.
93
+ Mutable session logs belong in explicit `scratchPatterns`, not in the landing
94
+ generator allowlist: their identity is frozen at final cleanup assessment, so
95
+ normal logging before teardown remains live while a later append or replacement
96
+ still stops deletion.
97
+ Landing-start blockers are classified before a journal is written, so moving a
98
+ protected blocker permits a clean next attempt. An explicit relinquish archives
99
+ either a started or frozen attempt, including drifted evidence, without
100
+ deleting or claiming any file; the next preflight treats every current file as
101
+ pre-existing. Exact unchanged frozen evidence remains directly resumable.
102
+
52
103
  `cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
53
104
  every linked worktree and local branch, reports issue/PR/merge/age/removal
54
105
  facts, and counts merged remote branches separately. It never removes a
55
106
  worktree or branch.
56
107
 
108
+ ## Session-owned teardown
109
+
110
+ `session.py` is the narrower orchestration route. It binds one receipt in the
111
+ Git common directory to the active `wave-active/<anchor>` annotated claim:
112
+
113
+ ```sh
114
+ python3 scripts/worktree-lifecycle/session.py begin --anchor <n> --owner <run> --base <wave-head>
115
+ python3 scripts/worktree-lifecycle/session.py create --anchor <n> --owner <run> --base <current-wave-head> <issue> <slug> <type>
116
+ python3 scripts/worktree-lifecycle/session.py recover --anchor <n> --owner <run> --branch <exact-branch>
117
+ python3 scripts/worktree-lifecycle/session.py seal --anchor <n> --owner <run>
118
+ python3 scripts/worktree-lifecycle/session.py inspect --anchor <n> --owner <run> --main origin/main
119
+ python3 scripts/worktree-lifecycle/session.py teardown --anchor <n> --owner <run> --main origin/main
120
+ ```
121
+
122
+ Only `create` can add an inventory row, and it refuses any branch or path that
123
+ already existed. It journals a provisional exact row before `git worktree add`,
124
+ then captures the shared artifact baseline before project setup and promotes
125
+ the row only after setup succeeds. Session inspection
126
+ therefore accepts generated scratch only when the exact path matches the
127
+ profile and is absent from the creation baseline; missing, changed, or
128
+ incoherent provenance stops. `seal` records the final exact branch OIDs.
129
+ Inspection reports ancestry, one-to-one stable patch equivalence, unique
130
+ content, and ambiguity separately. Empty commits, non-ancestry merge commits,
131
+ duplicate patch IDs, open or unreadable PR evidence, dirt, protected names,
132
+ stale registrations, recreated removed targets, and identity drift stop the
133
+ whole teardown before mutation.
134
+
135
+ If setup fails, the provisional row becomes `recovery-pending`. Automatic and
136
+ explicit `recover` use the same bounded route: active claim and receipt,
137
+ protected name, PR state, exact ref OID, registration/root identity, and the
138
+ creation baseline are revalidated. A branch-only remainder is compare-deleted
139
+ only at its provisional OID. A registered worktree whose add succeeded before
140
+ root journaling is adopted only when its Git registration/backlink, branch,
141
+ OID, and exact-clean inventory all match.
142
+
143
+ For failed project setup, exact index/worktree diff hashes and paths are frozen
144
+ without storing diff content. Exact regular-file or no-follow symlink identity
145
+ is frozen for every setup-created untracked path. Recovery revalidates that
146
+ evidence, restores only the frozen tracked paths to the creation OID, and
147
+ unlinks only matching untracked identities; later foreign files,
148
+ modifications, or replacements stop without losing receipt ownership. If
149
+ identity capture cannot finish, the receipt retains only a bounded failure
150
+ class and can retry from the unchanged inventory or an exact-clean worktree.
151
+ Raw command, exception, stdout, and stderr text is never retained or archived.
152
+ Recovery uses ordinary worktree removal and compare-deletes the exact ref.
153
+
154
+ Teardown re-runs that complete assessment, archives every recovery OID in the
155
+ receipt before its first mutation, and revalidates canonical main, the active
156
+ claim and receipt, profile protection, PR state, branch OID, worktree
157
+ registration/root identity, dirt, and the exact scratch inventory immediately
158
+ before each target mutation. After ordinary worktree removal it rechecks the
159
+ ref-side gates, then compare-deletes only the recorded local refs with
160
+ `git update-ref -d <ref> <expected-oid>`. A concurrent ref move therefore
161
+ survives, while a partial run keeps its recovery OIDs and can resume. Generic
162
+ cleanup and sweep never consume this ownership route and never infer authority
163
+ over a foreign branch.
164
+
57
165
  Claude hook wiring and any Codex adaptation consume this same profile and core.
58
166
  An adapter may change only the surface event envelope; it must preserve the
59
167
  core verdict and message.
@@ -39,7 +39,15 @@
39
39
  {
40
40
  "historicalPath": "scripts/cleanup-worktrees.sh",
41
41
  "artifact": "scripts/worktree-lifecycle/cleanup.py",
42
- "primitives": ["profile", "git-facts", "cleanup-safety"]
42
+ "supportingArtifacts": ["scripts/worktree-lifecycle/session.py"],
43
+ "primitives": [
44
+ "profile",
45
+ "git-facts",
46
+ "cleanup-safety",
47
+ "claim-bound-session-receipt",
48
+ "patch-equivalence",
49
+ "compare-and-delete"
50
+ ]
43
51
  }
44
52
  ]
45
53
  }
@@ -4,22 +4,22 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
- from contextlib import contextmanager
8
7
  from dataclasses import asdict, replace
9
8
  import json
10
- import os
11
- import stat
12
9
  import sys
13
- from pathlib import Path, PurePosixPath
10
+ from pathlib import Path
14
11
 
15
12
  from core import (
16
13
  LifecycleError,
14
+ bind_cleanup_scratch_evidence,
17
15
  classify_cleanup,
18
16
  collect_cleanup_facts,
19
17
  collect_sweep,
20
18
  load_profile,
21
19
  main_worktree,
20
+ remove_authorized_scratch,
22
21
  run,
22
+ verified_worktree_root,
23
23
  )
24
24
 
25
25
 
@@ -75,52 +75,18 @@ def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
75
75
  worktree,
76
76
  )
77
77
  state = pr_state(gh_command, main, facts.branch)
78
- return classify_cleanup(profile, replace(facts, pr_state=state))
79
-
80
-
81
- @contextmanager
82
- def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
83
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
84
- no_follow = getattr(os, "O_NOFOLLOW", 0)
85
- descriptor = None
78
+ assessment = classify_cleanup(profile, replace(facts, pr_state=state))
86
79
  try:
87
- descriptor = os.open(root, directory_flags | no_follow)
88
- metadata = os.fstat(descriptor)
89
- if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
90
- raise LifecycleError("worktree root changed before removal")
91
- yield descriptor
92
- except OSError as error:
93
- raise LifecycleError("worktree root changed before removal") from error
94
- finally:
95
- if descriptor is not None:
96
- os.close(descriptor)
97
-
98
-
99
- def remove_contained_regular(root_descriptor: int, relative: str) -> None:
100
- path = PurePosixPath(relative)
101
- if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
102
- raise LifecycleError(f"unsafe scratch path: {relative}")
103
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
104
- no_follow = getattr(os, "O_NOFOLLOW", 0)
105
- descriptors = []
106
- try:
107
- current = root_descriptor
108
- for component in path.parts[:-1]:
109
- current = os.open(
110
- component,
111
- directory_flags | no_follow,
112
- dir_fd=current,
113
- )
114
- descriptors.append(current)
115
- metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
116
- if not stat.S_ISREG(metadata.st_mode):
117
- raise LifecycleError(f"scratch path is not a regular file: {relative}")
118
- os.unlink(path.name, dir_fd=current)
119
- except OSError as error:
120
- raise LifecycleError(f"scratch path changed before removal: {relative}") from error
121
- finally:
122
- for descriptor in reversed(descriptors):
123
- os.close(descriptor)
80
+ return bind_cleanup_scratch_evidence(
81
+ profile,
82
+ assessment,
83
+ require_generator_evidence=True,
84
+ )
85
+ except LifecycleError as error:
86
+ return replace(
87
+ assessment,
88
+ reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
89
+ )
124
90
 
125
91
 
126
92
  def execute(args: argparse.Namespace) -> dict:
@@ -155,6 +121,7 @@ def execute(args: argparse.Namespace) -> dict:
155
121
  or latest.assumptions != assessment.assumptions
156
122
  or latest.root_device != assessment.root_device
157
123
  or latest.root_inode != assessment.root_inode
124
+ or latest.scratch_evidence != assessment.scratch_evidence
158
125
  ):
159
126
  raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
160
127
  with verified_worktree_root(
@@ -162,8 +129,12 @@ def execute(args: argparse.Namespace) -> dict:
162
129
  latest.root_device,
163
130
  latest.root_inode,
164
131
  ) as root_descriptor:
165
- for scratch in latest.scratch_files:
166
- remove_contained_regular(root_descriptor, scratch)
132
+ remove_authorized_scratch(
133
+ profile,
134
+ root_descriptor,
135
+ latest.scratch_files,
136
+ assessment.scratch_evidence,
137
+ )
167
138
  run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
168
139
  run(["git", "branch", "-d", assessment.branch], cwd=main)
169
140
  report["removed"] = True