@ikon85/agent-workflow-kit 0.36.5 → 0.37.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 (40) hide show
  1. package/.agents/skills/orchestrate-wave/SKILL.md +15 -7
  2. package/.agents/skills/setup-workflow/SKILL.md +16 -2
  3. package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  4. package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
  5. package/.agents/skills/wrapup/SKILL.md +20 -9
  6. package/.claude/hooks/migration-snapshot-reminder.py +1 -1
  7. package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
  8. package/.claude/skills/setup-workflow/SKILL.md +16 -2
  9. package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
  10. package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
  11. package/.claude/skills/skill-manifest.json +1 -1
  12. package/.claude/skills/wrapup/SKILL.md +11 -10
  13. package/README.md +52 -1
  14. package/agent-workflow-kit.package.json +26 -26
  15. package/docs/agents/workflow-capabilities.json +1 -0
  16. package/package.json +1 -1
  17. package/scripts/anchor_table.py +14 -8
  18. package/scripts/project-skill-extension.mjs +21 -2
  19. package/scripts/readiness.mjs +32 -4
  20. package/scripts/release-state.mjs +19 -9
  21. package/scripts/release-state.test.mjs +71 -8
  22. package/scripts/test_anchor_table.py +69 -0
  23. package/scripts/test_board_sync_create_idempotency.py +15 -2
  24. package/scripts/test_board_sync_wave_title.py +14 -2
  25. package/scripts/test_census_backstop.py +33 -0
  26. package/scripts/test_orchestrate_wave_contract.py +35 -0
  27. package/scripts/test_retro_wrapup_contract.py +19 -2
  28. package/scripts/test_wrapup_land.py +428 -0
  29. package/scripts/workflow-advisories/core.py +44 -2
  30. package/scripts/worktree-lifecycle/README.md +18 -4
  31. package/scripts/worktree-lifecycle/cleanup.py +173 -6
  32. package/scripts/worktree-lifecycle/core.py +331 -18
  33. package/scripts/worktree-lifecycle/profile.py +2 -0
  34. package/scripts/wrapup-land.py +336 -15
  35. package/src/cli.mjs +60 -27
  36. package/src/lib/manifest.mjs +173 -3
  37. package/src/lib/projectSkillExtension.mjs +78 -1
  38. package/src/lib/updateCandidate.mjs +3 -1
  39. package/src/lib/updateDecisions.mjs +2 -2
  40. package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
@@ -17,6 +17,8 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
17
17
  - `setupEntry` and ordered `setupSteps`: the portable setup command and project
18
18
  setup sequence.
19
19
  - `riskyCommandPatterns`: commands that must target the active linked worktree.
20
+ - `scratchPatterns`: consumer-owned glob patterns for untracked disposable
21
+ planning artefacts. No filename is assumed by Core.
20
22
 
21
23
  Unknown or malformed events fail open without changing repository state.
22
24
  Security-sensitive, profile-matched edits and commands fail closed only when
@@ -35,10 +37,22 @@ the core proves the target is unsafe.
35
37
 
36
38
  ## Cleanup
37
39
 
38
- `cleanup.py` previews by default. Removal refuses protected, dirty, or unmerged
39
- worktrees. The assessment reads `ANNAHMEN.md` before removal and returns its
40
- contents for propagation. `wrapup-land.py` invokes this same assessment after a
41
- merge and before killing processes or removing the worktree.
40
+ `cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
41
+ non-scratch-dirty, open-PR, or unmerged worktrees. Profile-declared untracked
42
+ scratch is named in the report but does not block removal. The assessment reads
43
+ `ANNAHMEN.md` before removal and returns its contents for propagation.
44
+ `wrapup-land.py` invokes this same assessment after a merge and before killing
45
+ processes or removing the worktree.
46
+
47
+ Explicit removal re-collects facts immediately before mutation, requires the
48
+ same removable inventory, deletes only the exact contained regular scratch
49
+ files from that inventory, and uses ordinary `git worktree remove`. It never
50
+ bypasses Git's final concurrent-change check with force removal.
51
+
52
+ `cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
53
+ every linked worktree and local branch, reports issue/PR/merge/age/removal
54
+ facts, and counts merged remote branches separately. It never removes a
55
+ worktree or branch.
42
56
 
43
57
  Claude hook wiring and any Codex adaptation consume this same profile and core.
44
58
  An adapter may change only the surface event envelope; it must preserve the
@@ -4,49 +4,216 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ from contextlib import contextmanager
8
+ from dataclasses import asdict, replace
7
9
  import json
10
+ import os
11
+ import stat
8
12
  import sys
9
- from pathlib import Path
13
+ from pathlib import Path, PurePosixPath
10
14
 
11
- from core import LifecycleError, cleanup_assessment, load_profile, main_worktree, run
15
+ from core import (
16
+ LifecycleError,
17
+ classify_cleanup,
18
+ collect_cleanup_facts,
19
+ collect_sweep,
20
+ load_profile,
21
+ main_worktree,
22
+ run,
23
+ )
12
24
 
13
25
 
14
26
  def parse_args() -> argparse.Namespace:
15
27
  parser = argparse.ArgumentParser()
16
28
  parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
17
29
  parser.add_argument("--remove", action="store_true")
18
- parser.add_argument("worktree")
30
+ parser.add_argument("--gh-command", default="gh")
31
+ parser.add_argument("worktree", nargs="?")
19
32
  return parser.parse_args()
20
33
 
21
34
 
35
+ def parse_sweep_args(argv: list[str]) -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(prog="cleanup.py sweep")
37
+ parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
38
+ parser.add_argument("--gh-command", default="gh")
39
+ return parser.parse_args(argv)
40
+
41
+
42
+ def pr_state(gh_command: str, main: Path, branch: str) -> str:
43
+ remote = run(
44
+ ["git", "remote", "get-url", "origin"],
45
+ cwd=main,
46
+ check=False,
47
+ )
48
+ if remote.returncode != 0:
49
+ return "none"
50
+ result = run(
51
+ [
52
+ gh_command, "pr", "list", "--state", "all", "--head", branch,
53
+ "--json", "number,state,mergedAt",
54
+ ],
55
+ cwd=main,
56
+ check=False,
57
+ )
58
+ if result.returncode != 0:
59
+ detail = (result.stderr or result.stdout).strip()
60
+ raise LifecycleError(f"cannot determine PR state for {branch}: {detail}")
61
+ try:
62
+ prs = json.loads(result.stdout)
63
+ except json.JSONDecodeError as error:
64
+ raise LifecycleError(f"cannot determine PR state for {branch}: invalid gh output") from error
65
+ if any(str(pr.get("state", "")).upper() == "OPEN" for pr in prs):
66
+ return "open"
67
+ if any(pr.get("mergedAt") or str(pr.get("state", "")).upper() == "MERGED" for pr in prs):
68
+ return "merged"
69
+ return "none"
70
+
71
+
72
+ def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
73
+ facts = collect_cleanup_facts(
74
+ main,
75
+ worktree,
76
+ )
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
86
+ 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)
124
+
125
+
22
126
  def execute(args: argparse.Namespace) -> dict:
23
127
  main = main_worktree(Path.cwd())
24
128
  profile_path = Path(args.profile)
25
129
  if not profile_path.is_absolute():
26
130
  profile_path = main / profile_path
27
131
  profile = load_profile(profile_path)
28
- assessment = cleanup_assessment(profile, main, Path(args.worktree))
132
+ if not args.worktree:
133
+ raise LifecycleError("worktree is required")
134
+ worktree = Path(args.worktree)
135
+ assessment = collect_assessment(profile, main, worktree, args.gh_command)
29
136
  report = {
30
137
  "worktree": str(assessment.worktree),
31
138
  "branch": assessment.branch,
32
139
  "removable": assessment.removable,
33
140
  "reasons": list(assessment.reasons),
34
141
  "assumptions": assessment.assumptions,
142
+ "scratchFiles": list(assessment.scratch_files),
35
143
  "removed": False,
36
144
  }
37
145
  if not args.remove:
38
146
  return report
39
147
  if not assessment.removable:
40
148
  raise LifecycleError("; ".join(assessment.reasons))
41
- run(["git", "worktree", "remove", str(assessment.worktree)], cwd=main)
149
+ latest = collect_assessment(profile, main, worktree, args.gh_command)
150
+ if not latest.removable:
151
+ raise LifecycleError(f"cleanup changed before removal: {'; '.join(latest.reasons)}")
152
+ if (
153
+ latest.branch != assessment.branch
154
+ or latest.scratch_files != assessment.scratch_files
155
+ or latest.assumptions != assessment.assumptions
156
+ or latest.root_device != assessment.root_device
157
+ or latest.root_inode != assessment.root_inode
158
+ ):
159
+ raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
160
+ with verified_worktree_root(
161
+ latest.worktree,
162
+ latest.root_device,
163
+ latest.root_inode,
164
+ ) as root_descriptor:
165
+ for scratch in latest.scratch_files:
166
+ remove_contained_regular(root_descriptor, scratch)
167
+ run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
42
168
  run(["git", "branch", "-d", assessment.branch], cwd=main)
43
169
  report["removed"] = True
44
170
  return report
45
171
 
46
172
 
173
+ def sweep(args: argparse.Namespace) -> dict:
174
+ main = main_worktree(Path.cwd())
175
+ profile_path = Path(args.profile)
176
+ if not profile_path.is_absolute():
177
+ profile_path = main / profile_path
178
+ profile = load_profile(profile_path)
179
+ report = collect_sweep(
180
+ profile,
181
+ main,
182
+ lambda branch: pr_state(args.gh_command, main, branch),
183
+ )
184
+ payload = asdict(report)
185
+ return {
186
+ "mainBranch": payload["main_branch"],
187
+ "worktreeCount": payload["worktree_count"],
188
+ "localBranchCount": payload["local_branch_count"],
189
+ "mergedRemoteBranchCount": payload["merged_remote_branch_count"],
190
+ "rows": [
191
+ {
192
+ "kind": row["kind"],
193
+ "path": row["path"],
194
+ "branch": row["branch"],
195
+ "issue": row["issue"],
196
+ "prState": row["pr_state"],
197
+ "mergedIntoMain": row["merged_into_main"],
198
+ "lastCommitAgeSeconds": row["last_commit_age_seconds"],
199
+ "removable": row["removable"],
200
+ "reasons": list(row["reasons"]),
201
+ "verdictReason": row["verdict_reason"],
202
+ "scratchFiles": list(row["scratch_files"]),
203
+ "assumptions": row["assumptions"],
204
+ }
205
+ for row in payload["rows"]
206
+ ],
207
+ }
208
+
209
+
47
210
  def main() -> int:
48
211
  try:
49
- print(json.dumps(execute(parse_args()), ensure_ascii=False, indent=2))
212
+ if len(sys.argv) > 1 and sys.argv[1] == "sweep":
213
+ result = sweep(parse_sweep_args(sys.argv[2:]))
214
+ else:
215
+ result = execute(parse_args())
216
+ print(json.dumps(result, ensure_ascii=False, indent=2))
50
217
  return 0
51
218
  except LifecycleError as error:
52
219
  print(f"STOP: {error}", file=sys.stderr)
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from fnmatch import fnmatchcase
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Any
9
+ from time import time
10
+ from typing import Any, Callable
9
11
 
10
12
  from profile import (
11
13
  LifecycleError,
@@ -44,12 +46,30 @@ class CleanupAssessment:
44
46
  branch: str
45
47
  assumptions: str
46
48
  reasons: tuple[str, ...]
49
+ root_device: int
50
+ root_inode: int
51
+ scratch_files: tuple[str, ...] = ()
47
52
 
48
53
  @property
49
54
  def removable(self) -> bool:
50
55
  return not self.reasons
51
56
 
52
57
 
58
+ @dataclass(frozen=True)
59
+ class CleanupFacts:
60
+ worktree: Path
61
+ branch: str
62
+ registered: bool
63
+ is_main: bool
64
+ tracked_files: tuple[str, ...]
65
+ untracked_files: tuple[str, ...]
66
+ merged: bool
67
+ pr_state: str
68
+ assumptions: str
69
+ root_device: int
70
+ root_inode: int
71
+
72
+
53
73
  def collect_facts(cwd: Path) -> RepoFacts:
54
74
  root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
55
75
  main = main_worktree(root)
@@ -121,28 +141,60 @@ def cleanup_assessment(
121
141
  main: Path,
122
142
  target: Path,
123
143
  merge_target: str | None = None,
144
+ pr_state: str = "none",
124
145
  ) -> CleanupAssessment:
146
+ return classify_cleanup(
147
+ profile,
148
+ collect_cleanup_facts(
149
+ main,
150
+ target,
151
+ merge_target=merge_target,
152
+ pr_state=pr_state,
153
+ ),
154
+ )
155
+
156
+
157
+ def collect_cleanup_facts(
158
+ main: Path,
159
+ target: Path,
160
+ *,
161
+ merge_target: str | None = None,
162
+ pr_state: str = "none",
163
+ ) -> CleanupFacts:
125
164
  worktree = target.resolve()
126
- reasons = []
165
+ root_metadata = worktree.stat()
127
166
  branch = run(
128
167
  ["git", "-C", str(worktree), "branch", "--show-current"],
129
168
  cwd=main,
130
169
  check=False,
131
170
  ).stdout.strip()
132
- if worktree not in registered_worktrees(main):
133
- reasons.append("not a registered worktree")
134
- if not branch:
135
- reasons.append("detached or unreadable branch")
136
- if branch in profile.protected_branches or worktree == main.resolve():
137
- reasons.append(f"protected worktree branch: {branch or '<unknown>'}")
138
- status = run(
139
- ["git", "-C", str(worktree), "status", "--porcelain"],
171
+ tracked = set(run(
172
+ ["git", "-C", str(worktree), "diff", "--name-only"],
173
+ cwd=main,
174
+ check=False,
175
+ ).stdout.splitlines())
176
+ tracked.update(run(
177
+ ["git", "-C", str(worktree), "diff", "--cached", "--name-only"],
178
+ cwd=main,
179
+ check=False,
180
+ ).stdout.splitlines())
181
+ untracked = set(run(
182
+ ["git", "-C", str(worktree), "ls-files", "--others", "--exclude-standard"],
140
183
  cwd=main,
141
184
  check=False,
142
- ).stdout
143
- if status.strip():
144
- reasons.append("dirty worktree")
145
- if branch and branch not in profile.protected_branches:
185
+ ).stdout.splitlines())
186
+ untracked.update(run(
187
+ [
188
+ "git", "-C", str(worktree), "ls-files", "--others", "--ignored",
189
+ "--exclude-standard",
190
+ ],
191
+ cwd=main,
192
+ check=False,
193
+ ).stdout.splitlines())
194
+ # ANNAHMEN.md is governed separately: its bytes are returned before removal.
195
+ untracked.discard("ANNAHMEN.md")
196
+ merged = False
197
+ if branch:
146
198
  main_branch = merge_target or run(
147
199
  ["git", "-C", str(main), "branch", "--show-current"],
148
200
  cwd=main,
@@ -152,12 +204,273 @@ def cleanup_assessment(
152
204
  ["git", "merge-base", "--is-ancestor", branch, main_branch],
153
205
  cwd=main,
154
206
  check=False,
155
- )
156
- if merged.returncode != 0:
157
- reasons.append(f"unmerged branch: {branch}")
207
+ ).returncode == 0
158
208
  assumptions_path = worktree / "ANNAHMEN.md"
159
209
  assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
160
- return CleanupAssessment(worktree, branch, assumptions, tuple(reasons))
210
+ return CleanupFacts(
211
+ worktree=worktree,
212
+ branch=branch,
213
+ registered=worktree in registered_worktrees(main),
214
+ is_main=worktree == main.resolve(),
215
+ tracked_files=tuple(sorted(tracked)),
216
+ untracked_files=tuple(sorted(untracked)),
217
+ merged=merged,
218
+ pr_state=pr_state,
219
+ assumptions=assumptions,
220
+ root_device=root_metadata.st_dev,
221
+ root_inode=root_metadata.st_ino,
222
+ )
223
+
224
+
225
+ def classify_cleanup(
226
+ profile: WorktreeProfile,
227
+ facts: CleanupFacts,
228
+ ) -> CleanupAssessment:
229
+ reasons = []
230
+ if not facts.registered:
231
+ reasons.append("not a registered worktree")
232
+ if not facts.branch:
233
+ reasons.append("detached or unreadable branch")
234
+ if facts.branch in profile.protected_branches or facts.is_main:
235
+ reasons.append(f"protected worktree branch: {facts.branch or '<unknown>'}")
236
+ scratch = sorted(
237
+ path for path in facts.untracked_files
238
+ if any(fnmatchcase(path, pattern) for pattern in profile.scratch_patterns)
239
+ )
240
+ non_scratch = sorted(set(facts.untracked_files).difference(scratch))
241
+ if facts.tracked_files:
242
+ reasons.append(
243
+ f"dirty worktree: tracked modifications: {', '.join(facts.tracked_files)}"
244
+ )
245
+ if non_scratch:
246
+ reasons.append(f"dirty worktree: untracked non-scratch: {', '.join(non_scratch)}")
247
+ if facts.pr_state == "open":
248
+ reasons.append("open PR")
249
+ if (
250
+ facts.branch
251
+ and facts.branch not in profile.protected_branches
252
+ and not facts.merged
253
+ ):
254
+ reasons.append(f"unmerged branch: {facts.branch}")
255
+ return CleanupAssessment(
256
+ facts.worktree,
257
+ facts.branch,
258
+ facts.assumptions,
259
+ tuple(reasons),
260
+ facts.root_device,
261
+ facts.root_inode,
262
+ tuple(scratch),
263
+ )
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class SweepRow:
268
+ kind: str
269
+ path: str | None
270
+ branch: str
271
+ issue: str | None
272
+ pr_state: str
273
+ merged_into_main: bool
274
+ last_commit_age_seconds: int
275
+ removable: bool
276
+ reasons: tuple[str, ...]
277
+ verdict_reason: str
278
+ scratch_files: tuple[str, ...] = ()
279
+ assumptions: str = ""
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class SweepReport:
284
+ main_branch: str
285
+ worktree_count: int
286
+ local_branch_count: int
287
+ merged_remote_branch_count: int
288
+ rows: tuple[SweepRow, ...]
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class SweepFactRow:
293
+ path: Path | None
294
+ branch: str
295
+ pr_state: str
296
+ merged_into_main: bool
297
+ last_commit_age_seconds: int
298
+ cleanup: CleanupFacts | None = None
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class SweepFacts:
303
+ main: Path
304
+ main_branch: str
305
+ worktree_count: int
306
+ local_branch_count: int
307
+ merged_remote_branch_count: int
308
+ rows: tuple[SweepFactRow, ...]
309
+
310
+
311
+ def _worktree_branches(main: Path) -> tuple[dict[str, Path], tuple[Path, ...]]:
312
+ output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
313
+ linked: dict[str, Path] = {}
314
+ detached = []
315
+ path: Path | None = None
316
+ branch = ""
317
+ for line in [*output.splitlines(), ""]:
318
+ if line.startswith("worktree "):
319
+ path = Path(line.split(" ", 1)[1]).resolve()
320
+ branch = ""
321
+ elif line.startswith("branch refs/heads/"):
322
+ branch = line.removeprefix("branch refs/heads/")
323
+ elif not line and path is not None:
324
+ if branch:
325
+ linked[branch] = path
326
+ else:
327
+ detached.append(path)
328
+ path = None
329
+ return linked, tuple(detached)
330
+
331
+
332
+ def collect_sweep_facts(
333
+ profile: WorktreeProfile,
334
+ main: Path,
335
+ pr_lookup: Callable[[str], str],
336
+ *,
337
+ now: int | None = None,
338
+ ) -> SweepFacts:
339
+ """Gather the complete read-only inventory without making removal decisions."""
340
+ main = main.resolve()
341
+ main_branch = run(
342
+ ["git", "-C", str(main), "branch", "--show-current"], cwd=main
343
+ ).stdout.strip()
344
+ linked, detached = _worktree_branches(main)
345
+ refs = run(
346
+ [
347
+ "git", "for-each-ref",
348
+ "--format=%(refname:short)\t%(committerdate:unix)",
349
+ "refs/heads/",
350
+ ],
351
+ cwd=main,
352
+ ).stdout.splitlines()
353
+ timestamp = int(time()) if now is None else now
354
+ rows: list[SweepFactRow] = []
355
+ for line in refs:
356
+ branch, commit_time = line.rsplit("\t", 1)
357
+ path = linked.get(branch)
358
+ pr_state = pr_lookup(branch)
359
+ merged = run(
360
+ ["git", "merge-base", "--is-ancestor", branch, main_branch],
361
+ cwd=main,
362
+ check=False,
363
+ ).returncode == 0
364
+ rows.append(SweepFactRow(
365
+ path=path,
366
+ branch=branch,
367
+ pr_state=pr_state,
368
+ merged_into_main=merged,
369
+ last_commit_age_seconds=max(0, timestamp - int(commit_time)),
370
+ cleanup=collect_cleanup_facts(
371
+ main,
372
+ path,
373
+ merge_target=main_branch,
374
+ pr_state=pr_state,
375
+ ) if path is not None else None,
376
+ ))
377
+ for path in detached:
378
+ commit_time = run(
379
+ ["git", "-C", str(path), "show", "-s", "--format=%ct", "HEAD"],
380
+ cwd=main,
381
+ ).stdout.strip()
382
+ rows.append(SweepFactRow(
383
+ path=path,
384
+ branch="",
385
+ pr_state="none",
386
+ merged_into_main=False,
387
+ last_commit_age_seconds=max(0, timestamp - int(commit_time)),
388
+ ))
389
+ remote_merged = run(
390
+ [
391
+ "git", "for-each-ref",
392
+ f"--merged={main_branch}",
393
+ "--format=%(refname:short)",
394
+ "refs/remotes/",
395
+ ],
396
+ cwd=main,
397
+ check=False,
398
+ ).stdout.splitlines()
399
+ return SweepFacts(
400
+ main=main,
401
+ main_branch=main_branch,
402
+ worktree_count=len(linked) + len(detached),
403
+ local_branch_count=len(refs),
404
+ merged_remote_branch_count=len([
405
+ branch for branch in remote_merged if branch and not branch.endswith("/HEAD")
406
+ ]),
407
+ rows=tuple(rows),
408
+ )
409
+
410
+
411
+ def classify_sweep(profile: WorktreeProfile, facts: SweepFacts) -> SweepReport:
412
+ """Apply profile policy to already-collected inventory facts."""
413
+ rows = []
414
+ for fact in facts.rows:
415
+ if fact.cleanup is not None:
416
+ assessment = classify_cleanup(profile, fact.cleanup)
417
+ reasons = assessment.reasons
418
+ scratch = assessment.scratch_files
419
+ assumptions = assessment.assumptions
420
+ elif fact.path is not None:
421
+ reasons = ("detached or unreadable branch",)
422
+ scratch = ()
423
+ assumptions = ""
424
+ else:
425
+ reasons_list = []
426
+ if fact.branch in profile.protected_branches:
427
+ reasons_list.append(f"protected branch: {fact.branch}")
428
+ if fact.pr_state == "open":
429
+ reasons_list.append("open PR")
430
+ if not fact.merged_into_main:
431
+ reasons_list.append(f"unmerged branch: {fact.branch}")
432
+ reasons = tuple(reasons_list)
433
+ scratch = ()
434
+ assumptions = ""
435
+ rows.append(SweepRow(
436
+ kind="worktree" if fact.path is not None else "branch",
437
+ path=str(fact.path) if fact.path is not None else None,
438
+ branch=fact.branch,
439
+ issue=profile.issue_from_branch(fact.branch),
440
+ pr_state=fact.pr_state,
441
+ merged_into_main=fact.merged_into_main,
442
+ last_commit_age_seconds=fact.last_commit_age_seconds,
443
+ removable=not reasons,
444
+ reasons=reasons,
445
+ verdict_reason=(
446
+ "; ".join(reasons)
447
+ if reasons
448
+ else (
449
+ f"merged into {facts.main_branch}; scratch-only: {', '.join(scratch)}"
450
+ if scratch
451
+ else f"merged into {facts.main_branch}; no blocking work"
452
+ )
453
+ ),
454
+ scratch_files=scratch,
455
+ assumptions=assumptions,
456
+ ))
457
+ return SweepReport(
458
+ main_branch=facts.main_branch,
459
+ worktree_count=facts.worktree_count,
460
+ local_branch_count=facts.local_branch_count,
461
+ merged_remote_branch_count=facts.merged_remote_branch_count,
462
+ rows=tuple(rows),
463
+ )
464
+
465
+
466
+ def collect_sweep(
467
+ profile: WorktreeProfile,
468
+ main: Path,
469
+ pr_lookup: Callable[[str], str],
470
+ *,
471
+ now: int | None = None,
472
+ ) -> SweepReport:
473
+ return classify_sweep(profile, collect_sweep_facts(profile, main, pr_lookup, now=now))
161
474
 
162
475
 
163
476
  def edit_decision(
@@ -25,6 +25,7 @@ class WorktreeProfile:
25
25
  branch_regex: str
26
26
  setup_entry: str
27
27
  risky_command_patterns: tuple[str, ...]
28
+ scratch_patterns: tuple[str, ...]
28
29
 
29
30
  def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
30
31
  return _render(self.branch_template, issue, slug, branch_type)
@@ -74,6 +75,7 @@ def load_profile(path: Path) -> WorktreeProfile:
74
75
  r"\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|typecheck|build)\b",
75
76
  r"\bgit\s+(?:commit|push)\b",
76
77
  )),
78
+ scratch_patterns=tuple(raw.get("scratchPatterns") or ()),
77
79
  )
78
80
 
79
81