@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
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env python3
2
+ """Stateless teardown classification.
3
+
4
+ Teardown authority comes from the repository's current state, read at the
5
+ moment of action, and from nothing else. Four rules decide it:
6
+
7
+ 1. A tracked change (porcelain v2 records `1` and `2`) blocks.
8
+ 2. An unmerged path (record `u`) is tracked work and blocks.
9
+ 3. An untracked, non-ignored file (record `?`) blocks with a bounded report:
10
+ the exact file count plus the top directories, never a path dump.
11
+ 4. An ignored entry (record `!`) is Scratch and is deletable — with one
12
+ hardcoded carve-out, `.env*` by basename glob, which is deletable only when
13
+ it is byte-identical to its counterpart at the same relative path in the
14
+ main checkout, both opened no-follow.
15
+
16
+ An ignored symlink is deletable only when its target resolves inside the
17
+ assessed worktree; the link itself is unlinked and never followed. Absolute
18
+ targets, escaping targets, dangling targets, and a target that changed between
19
+ assessment and action are hard stops that name the link. The containment check
20
+ applies to the ignored entries git itself reports: git collapses an ignored
21
+ directory into a single record, and the contained recursive removal below
22
+ never follows a symlink, so a pnpm-style symlink farm inside such a directory
23
+ is removable with zero configuration.
24
+
25
+ One assessment object serves both surfaces: `assess()` returns it, a preview
26
+ renders it with `render_report()`, and the action consumes exactly that object
27
+ in `remove_scratch()`. There is no second formatter and no persisted evidence —
28
+ idempotency comes from re-running the assessment, not from a journal.
29
+
30
+ Deletion policy has exactly one configuration surface, the ignore mechanism:
31
+ this module reads no consumer-profile pattern list.
32
+
33
+ ## Residual risks — accepted deliberately
34
+
35
+ - Between assessment and deletion a file could in principle be replaced.
36
+ - A valuable file a consumer keeps gitignored outside `.env*` is deletable at
37
+ teardown.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import fnmatch
43
+ import io
44
+ import os
45
+ import stat
46
+ import subprocess
47
+ from collections import Counter
48
+ from contextlib import contextmanager
49
+ from dataclasses import dataclass
50
+ from pathlib import Path, PurePosixPath
51
+
52
+ TOP_DIRECTORY_LIMIT = 5
53
+ EXAMPLE_LIMIT = 5
54
+ ENV_BASENAME_GLOB = ".env*"
55
+
56
+ RULE_TRACKED = "tracked-change"
57
+ RULE_UNMERGED = "unmerged-path"
58
+ RULE_UNTRACKED = "untracked-files"
59
+ RULE_ENV = "env-file"
60
+ RULE_SYMLINK = "ignored-symlink"
61
+
62
+ _READ_CHUNK = 128 * 1024
63
+ _ITEM_LIMIT = 200
64
+ _DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
65
+ _FILE_FLAGS = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
66
+ # Path fields per porcelain v2 record type; the path is the field after them.
67
+ _LEADING_FIELDS = {"1": 8, "2": 9, "u": 10}
68
+
69
+
70
+ class ClassificationError(RuntimeError):
71
+ """Teardown cannot be classified or acted on safely."""
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Scratch:
76
+ """One ignored entry cleared for deletion."""
77
+
78
+ path: str
79
+ kind: str # "file" | "directory" | "symlink"
80
+ link_target: str | None = None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Block:
85
+ """One fired rule: what it saw, capped examples, and the fix."""
86
+
87
+ rule: str
88
+ summary: str
89
+ items: tuple[str, ...]
90
+ item_count: int
91
+ fix: str
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Assessment:
96
+ """The single report object shared by preview and action."""
97
+
98
+ worktree: str
99
+ main_checkout: str
100
+ root_device: int
101
+ root_inode: int
102
+ scratch: tuple[Scratch, ...]
103
+ blocks: tuple[Block, ...]
104
+
105
+ @property
106
+ def removable(self) -> bool:
107
+ return not self.blocks
108
+
109
+
110
+ def assess(worktree, main_checkout) -> Assessment:
111
+ """Classify the worktree's current state into scratch plus blocking rules."""
112
+ worktree = Path(worktree)
113
+ main_checkout = Path(main_checkout)
114
+ metadata = os.lstat(worktree)
115
+ if not stat.S_ISDIR(metadata.st_mode):
116
+ raise ClassificationError(f"worktree is not a directory: {worktree}")
117
+ buckets: dict[str, list[str]] = {"1": [], "u": [], "?": [], "!": []}
118
+ for kind, paths in _status_records(worktree):
119
+ buckets["1" if kind == "2" else kind].extend(paths)
120
+ scratch, env_offenders, link_offenders = _classify_ignored(
121
+ worktree, main_checkout, buckets["!"]
122
+ )
123
+ blocks = [
124
+ block
125
+ for block in (
126
+ _named_block(RULE_TRACKED, buckets["1"], "tracked change", "commit or stash"),
127
+ _named_block(RULE_UNMERGED, buckets["u"], "unmerged path", "resolve and commit"),
128
+ _untracked_block(buckets["?"]),
129
+ _named_block(RULE_ENV, env_offenders, ".env* file", _ENV_FIX),
130
+ _named_block(RULE_SYMLINK, link_offenders, "ignored symlink", _SYMLINK_FIX),
131
+ )
132
+ if block is not None
133
+ ]
134
+ return Assessment(
135
+ str(worktree),
136
+ str(main_checkout),
137
+ metadata.st_dev,
138
+ metadata.st_ino,
139
+ tuple(sorted(scratch, key=lambda entry: entry.path)),
140
+ tuple(blocks),
141
+ )
142
+
143
+
144
+ _ENV_FIX = (
145
+ "copy the named .env* file out or make it identical to the main checkout copy"
146
+ )
147
+ _SYMLINK_FIX = "remove the named symlink or point it inside the worktree"
148
+
149
+
150
+ def _status_records(worktree: Path):
151
+ """Read `git status --porcelain=v2 -z` and split it back into records."""
152
+ # `--ignored=matching` keeps an ignored directory one record instead of
153
+ # thousands; `-z` is required because paths carry spaces, quotes and
154
+ # newlines; `--untracked-files=all` makes the untracked count exact.
155
+ command = ["git", "-C", str(worktree), "status", "--porcelain=v2", "-z"]
156
+ command += ["--untracked-files=all", "--ignored=matching"]
157
+ completed = subprocess.run(command, capture_output=True)
158
+ if completed.returncode != 0:
159
+ detail = completed.stderr.decode("utf-8", "replace").strip()
160
+ raise ClassificationError(f"git status failed in {worktree}: {detail}")
161
+ return _parse_status(completed.stdout)
162
+
163
+
164
+ def _parse_status(payload: bytes):
165
+ """Yield (record type, paths) — a rename carries two NUL-separated paths."""
166
+ chunks = payload.split(b"\0")
167
+ records = []
168
+ index = 0
169
+ while index < len(chunks):
170
+ raw = chunks[index]
171
+ index += 1
172
+ if not raw:
173
+ continue
174
+ kind = chr(raw[0])
175
+ if kind in ("?", "!"):
176
+ records.append((kind, [os.fsdecode(raw[2:])]))
177
+ continue
178
+ if kind not in _LEADING_FIELDS:
179
+ raise ClassificationError(f"unsupported git status record: {kind!r}")
180
+ fields = raw.split(b" ", _LEADING_FIELDS[kind])
181
+ if len(fields) != _LEADING_FIELDS[kind] + 1:
182
+ raise ClassificationError(f"malformed git status record: {raw!r}")
183
+ paths = [os.fsdecode(fields[-1])]
184
+ if kind == "2":
185
+ if index >= len(chunks):
186
+ raise ClassificationError("truncated rename record: origin path missing")
187
+ paths.append(os.fsdecode(chunks[index]))
188
+ index += 1
189
+ records.append((kind, paths))
190
+ return records
191
+
192
+
193
+ def _classify_ignored(worktree: Path, main_checkout: Path, ignored: list[str]):
194
+ """Split ignored entries into scratch, `.env*` offenders and link offenders."""
195
+ scratch: list[Scratch] = []
196
+ env_offenders: list[str] = []
197
+ link_offenders: list[str] = []
198
+ for entry in ignored:
199
+ relative = entry[:-1] if entry.endswith("/") else entry
200
+ metadata = _lstat(worktree, relative)
201
+ if _is_env(relative):
202
+ problem = _env_problem(worktree, main_checkout, relative, metadata)
203
+ if problem is None:
204
+ scratch.append(Scratch(relative, "file"))
205
+ else:
206
+ env_offenders.append(f"{relative} — {problem}")
207
+ elif stat.S_ISLNK(metadata.st_mode):
208
+ problem, target = _link_problem(worktree, relative)
209
+ if problem is None:
210
+ scratch.append(Scratch(relative, "symlink", target))
211
+ else:
212
+ link_offenders.append(f"{relative} — {problem}")
213
+ elif stat.S_ISDIR(metadata.st_mode):
214
+ nested = _nested_env_problems(worktree, main_checkout, relative)
215
+ env_offenders.extend(nested)
216
+ if not nested:
217
+ scratch.append(Scratch(relative, "directory"))
218
+ else:
219
+ scratch.append(Scratch(relative, "file"))
220
+ return scratch, env_offenders, link_offenders
221
+
222
+
223
+ def _lstat(root: Path, relative: str):
224
+ try:
225
+ return os.lstat(root / relative)
226
+ except OSError as error:
227
+ raise ClassificationError(f"cannot read {relative}: {error}") from error
228
+
229
+
230
+ def _is_env(relative: str) -> bool:
231
+ return fnmatch.fnmatchcase(PurePosixPath(relative).name, ENV_BASENAME_GLOB)
232
+
233
+
234
+ def _nested_env_problems(worktree: Path, main_checkout: Path, relative: str):
235
+ """Find `.env*` files inside an ignored directory git reported as one entry."""
236
+ problems = []
237
+ base = worktree / relative
238
+ for directory, _subdirectories, names in os.walk(base, followlinks=False):
239
+ for name in names:
240
+ if not fnmatch.fnmatchcase(name, ENV_BASENAME_GLOB):
241
+ continue
242
+ nested = PurePosixPath(relative) / os.path.relpath(
243
+ os.path.join(directory, name), base
244
+ )
245
+ problem = _env_problem(
246
+ worktree, main_checkout, str(nested), _lstat(worktree, str(nested))
247
+ )
248
+ if problem is not None:
249
+ problems.append(f"{nested} — {problem}")
250
+ return sorted(problems)
251
+
252
+
253
+ def _env_problem(worktree: Path, main_checkout: Path, relative: str, metadata):
254
+ """Compare one `.env*` file with its main-checkout counterpart, no-follow."""
255
+ if not stat.S_ISREG(metadata.st_mode):
256
+ return "not a regular file in the worktree"
257
+ try:
258
+ with _opened_regular(worktree, relative) as left:
259
+ with _opened_regular(main_checkout, relative) as right:
260
+ if left is None or right is None:
261
+ return "absent from the main checkout, or not a regular file there"
262
+ return None if _same_bytes(left, right) else "differs from the main checkout copy"
263
+ except OSError as error:
264
+ raise ClassificationError(f"cannot compare {relative}: {error}") from error
265
+
266
+
267
+ @contextmanager
268
+ def _opened_regular(root: Path, relative: str):
269
+ """Open a regular file below root, following nothing; yield None if it is
270
+ missing, unreachable, or not a regular file — all three block a `.env*`."""
271
+ stream = None
272
+ try:
273
+ try:
274
+ with _opened_directory(root) as root_descriptor:
275
+ with _contained_parent(root_descriptor, relative) as (parent, name):
276
+ descriptor = os.open(name, _FILE_FLAGS, dir_fd=parent)
277
+ if stat.S_ISREG(os.fstat(descriptor).st_mode):
278
+ stream = io.open(descriptor, "rb")
279
+ else:
280
+ os.close(descriptor)
281
+ except OSError:
282
+ stream = None
283
+ yield stream
284
+ finally:
285
+ if stream is not None:
286
+ stream.close()
287
+
288
+
289
+ def _same_bytes(left, right) -> bool:
290
+ if os.fstat(left.fileno()).st_size != os.fstat(right.fileno()).st_size:
291
+ return False
292
+ while True:
293
+ chunk = left.read(_READ_CHUNK)
294
+ if chunk != right.read(_READ_CHUNK):
295
+ return False
296
+ if not chunk:
297
+ return True
298
+
299
+
300
+ def _link_problem(worktree: Path, relative: str):
301
+ """Decide whether an ignored symlink stays contained; never follow it."""
302
+ link = worktree / relative
303
+ target = os.readlink(link)
304
+ if os.path.isabs(target):
305
+ return "absolute target", target
306
+ if not os.path.exists(link):
307
+ return "dangling target", target
308
+ inside = os.path.realpath(worktree)
309
+ resolved = os.path.realpath(link)
310
+ if resolved != inside and not resolved.startswith(inside + os.sep):
311
+ return "target escapes the worktree", target
312
+ return None, target
313
+
314
+
315
+ def _named_block(rule: str, items, noun: str, fix: str):
316
+ if not items:
317
+ return None
318
+ unique = sorted(dict.fromkeys(items))
319
+ one = len(unique) == 1
320
+ return Block(
321
+ rule,
322
+ f"{len(unique)} {noun}{'' if one else 's'} {'blocks' if one else 'block'} teardown",
323
+ tuple(_shorten(item) for item in unique[:EXAMPLE_LIMIT]),
324
+ len(unique),
325
+ f"{fix}, then run teardown again",
326
+ )
327
+
328
+
329
+ def _untracked_block(paths):
330
+ if not paths:
331
+ return None
332
+ counts = Counter(_parent(path) for path in paths)
333
+ top = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))[:TOP_DIRECTORY_LIMIT]
334
+ files = _count(len(paths), "untracked file")
335
+ return Block(
336
+ RULE_UNTRACKED,
337
+ f"{files} in {_count(len(counts), 'directory', 'directories')} "
338
+ f"{'is' if len(paths) == 1 else 'are'} not ignored",
339
+ tuple(
340
+ f"{_shorten(directory)} ({_count(count, 'file')})"
341
+ for directory, count in top
342
+ ),
343
+ len(counts),
344
+ "ignore or remove the untracked files, then run teardown again",
345
+ )
346
+
347
+
348
+ def _count(total: int, noun: str, plural: str | None = None) -> str:
349
+ return f"{total} {noun}" if total == 1 else f"{total} {plural or noun + 's'}"
350
+
351
+
352
+ def _parent(path: str) -> str:
353
+ parent = str(PurePosixPath(path).parent)
354
+ return "./" if parent == "." else f"{parent}/"
355
+
356
+
357
+ def _shorten(text: str) -> str:
358
+ """Truncate one reported item and make it printable.
359
+
360
+ Paths arrive decoded with surrogateescape, so a non-UTF-8 filename would
361
+ otherwise raise while the refusal is being printed — a report that cannot
362
+ be shown names nothing, which is the failure this module replaces.
363
+ """
364
+ printable = text.encode("utf-8", "replace").decode("utf-8")
365
+ return printable if len(printable) <= _ITEM_LIMIT else f"{printable[:_ITEM_LIMIT]}…"
366
+
367
+
368
+ def render_report(assessment: Assessment) -> str:
369
+ """Render the one assessment object — always bounded, never a path dump."""
370
+ if assessment.removable:
371
+ preview = [entry.path for entry in assessment.scratch[:EXAMPLE_LIMIT]]
372
+ lines = [f"Teardown is clear: {len(assessment.scratch)} scratch entries are deletable."]
373
+ lines.extend(f" - {_shorten(path)}" for path in preview)
374
+ if len(assessment.scratch) > len(preview):
375
+ lines.append(f" … {len(assessment.scratch) - len(preview)} more (not listed)")
376
+ return "\n".join(lines)
377
+ lines = []
378
+ for block in assessment.blocks:
379
+ lines.append(f"[{block.rule}] {block.summary}:")
380
+ lines.extend(f" - {item}" for item in block.items)
381
+ if block.item_count > len(block.items):
382
+ lines.append(f" … {block.item_count - len(block.items)} more (not listed)")
383
+ lines.append(f" fix: {block.fix}")
384
+ return "\n".join(lines)
385
+
386
+
387
+ def remove_scratch(assessment: Assessment) -> tuple[str, ...]:
388
+ """Delete every entry the given assessment cleared — the action's only input."""
389
+ if not assessment.removable:
390
+ raise ClassificationError(
391
+ "teardown is blocked:\n" + render_report(assessment)
392
+ )
393
+ removed = []
394
+ with _verified_root(assessment) as root_descriptor:
395
+ for entry in assessment.scratch:
396
+ _remove_entry(root_descriptor, entry)
397
+ removed.append(entry.path)
398
+ return tuple(removed)
399
+
400
+
401
+ @contextmanager
402
+ def _opened_directory(path: Path):
403
+ descriptor = os.open(path, _DIRECTORY_FLAGS)
404
+ try:
405
+ yield descriptor
406
+ finally:
407
+ os.close(descriptor)
408
+
409
+
410
+ @contextmanager
411
+ def _verified_root(assessment: Assessment):
412
+ """Re-open the assessed root no-follow and re-check its identity."""
413
+ try:
414
+ with _opened_directory(Path(assessment.worktree)) as descriptor:
415
+ metadata = os.fstat(descriptor)
416
+ if (metadata.st_dev, metadata.st_ino) != (
417
+ assessment.root_device,
418
+ assessment.root_inode,
419
+ ):
420
+ raise ClassificationError("worktree root changed before removal")
421
+ yield descriptor
422
+ except OSError as error:
423
+ raise ClassificationError("worktree root changed before removal") from error
424
+
425
+
426
+ @contextmanager
427
+ def _contained_parent(root_descriptor: int, relative: str):
428
+ """Walk to the entry's parent directory without following any symlink."""
429
+ parts = PurePosixPath(relative).parts
430
+ if PurePosixPath(relative).is_absolute() or not parts or any(
431
+ part in {"", ".", ".."} for part in parts
432
+ ):
433
+ raise ClassificationError(f"unsafe scratch path: {relative}")
434
+ opened = []
435
+ current = root_descriptor
436
+ try:
437
+ for component in parts[:-1]:
438
+ current = os.open(component, _DIRECTORY_FLAGS, dir_fd=current)
439
+ opened.append(current)
440
+ yield current, parts[-1]
441
+ finally:
442
+ for descriptor in reversed(opened):
443
+ os.close(descriptor)
444
+
445
+
446
+ def _remove_entry(root_descriptor: int, entry: Scratch) -> None:
447
+ """Delete one assessed entry, re-checking its kind immediately before."""
448
+ try:
449
+ with _contained_parent(root_descriptor, entry.path) as (parent, name):
450
+ metadata = os.lstat(name, dir_fd=parent)
451
+ if entry.kind == "symlink":
452
+ if not stat.S_ISLNK(metadata.st_mode):
453
+ raise ClassificationError(f"scratch entry changed: {entry.path}")
454
+ if os.readlink(name, dir_fd=parent) != entry.link_target:
455
+ raise ClassificationError(f"symlink target changed: {entry.path}")
456
+ os.unlink(name, dir_fd=parent)
457
+ elif entry.kind == "directory":
458
+ if not stat.S_ISDIR(metadata.st_mode):
459
+ raise ClassificationError(f"scratch entry changed: {entry.path}")
460
+ _remove_tree(parent, name)
461
+ else:
462
+ if stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
463
+ raise ClassificationError(f"scratch entry changed: {entry.path}")
464
+ os.unlink(name, dir_fd=parent)
465
+ except OSError as error:
466
+ raise ClassificationError(f"scratch entry changed: {entry.path}") from error
467
+
468
+
469
+ def _remove_tree(parent_descriptor: int, name: str) -> None:
470
+ """Remove a directory tree contained by dir_fd walking, following nothing."""
471
+ descriptor = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_descriptor)
472
+ try:
473
+ for entry in os.scandir(descriptor):
474
+ if entry.is_dir(follow_symlinks=False):
475
+ _remove_tree(descriptor, entry.name)
476
+ else:
477
+ os.unlink(entry.name, dir_fd=descriptor)
478
+ finally:
479
+ os.close(descriptor)
480
+ os.rmdir(name, dir_fd=parent_descriptor)
@@ -9,17 +9,15 @@ import json
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
+ from classify import ClassificationError, remove_scratch, render_report
12
13
  from core import (
13
14
  LifecycleError,
14
- bind_cleanup_scratch_evidence,
15
15
  classify_cleanup,
16
16
  collect_cleanup_facts,
17
17
  collect_sweep,
18
18
  load_profile,
19
19
  main_worktree,
20
- remove_authorized_scratch,
21
20
  run,
22
- verified_worktree_root,
23
21
  )
24
22
 
25
23
 
@@ -75,18 +73,7 @@ def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
75
73
  worktree,
76
74
  )
77
75
  state = pr_state(gh_command, main, facts.branch)
78
- assessment = classify_cleanup(profile, replace(facts, pr_state=state))
79
- try:
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
- )
76
+ return classify_cleanup(profile, replace(facts, pr_state=state))
90
77
 
91
78
 
92
79
  def execute(args: argparse.Namespace) -> dict:
@@ -106,6 +93,7 @@ def execute(args: argparse.Namespace) -> dict:
106
93
  "reasons": list(assessment.reasons),
107
94
  "assumptions": assessment.assumptions,
108
95
  "scratchFiles": list(assessment.scratch_files),
96
+ "classification": render_report(assessment.classification),
109
97
  "removed": False,
110
98
  }
111
99
  if not args.remove:
@@ -119,22 +107,14 @@ def execute(args: argparse.Namespace) -> dict:
119
107
  latest.branch != assessment.branch
120
108
  or latest.scratch_files != assessment.scratch_files
121
109
  or latest.assumptions != assessment.assumptions
122
- or latest.root_device != assessment.root_device
123
- or latest.root_inode != assessment.root_inode
124
- or latest.scratch_evidence != assessment.scratch_evidence
110
+ or (latest.classification.root_device, latest.classification.root_inode)
111
+ != (assessment.classification.root_device, assessment.classification.root_inode)
125
112
  ):
126
113
  raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
127
- with verified_worktree_root(
128
- latest.worktree,
129
- latest.root_device,
130
- latest.root_inode,
131
- ) as root_descriptor:
132
- remove_authorized_scratch(
133
- profile,
134
- root_descriptor,
135
- latest.scratch_files,
136
- assessment.scratch_evidence,
137
- )
114
+ try:
115
+ remove_scratch(latest.classification)
116
+ except ClassificationError as error:
117
+ raise LifecycleError(f"cleanup changed before removal: {error}") from error
138
118
  run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
139
119
  run(["git", "branch", "-d", assessment.branch], cwd=main)
140
120
  report["removed"] = True