@ikon85/agent-workflow-kit 0.43.0 → 0.44.1

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 (127) 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/skill-manifest.json +1 -1
  22. package/.claude/skills/to-issues/SKILL.md +43 -0
  23. package/.claude/skills/wrapup/SKILL.md +98 -54
  24. package/README.md +254 -7
  25. package/agent-workflow-kit.package.json +43 -43
  26. package/package.json +16 -12
  27. package/scripts/marker_lib.py +3 -0
  28. package/scripts/profile_globs.py +26 -45
  29. package/scripts/release-delta-guard.mjs +26 -9
  30. package/scripts/worktree-lifecycle/README.md +57 -160
  31. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  32. package/scripts/worktree-lifecycle/classify.py +480 -0
  33. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  34. package/scripts/worktree-lifecycle/core.py +68 -1429
  35. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  36. package/scripts/worktree-lifecycle/profile.py +46 -29
  37. package/scripts/worktree-lifecycle/setup.py +7 -19
  38. package/scripts/wrapup-land.py +1292 -545
  39. package/src/consumer-migrations.json +1 -16
  40. package/src/lib/bundle.mjs +31 -1
  41. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  42. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  43. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  44. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  45. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  46. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  47. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  48. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  49. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  50. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  51. package/docs/agents/board-sync.md +0 -74
  52. package/docs/agents/code-review.md +0 -14
  53. package/docs/agents/workflow-capabilities.json +0 -28
  54. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  55. package/docs/research/benchlm-routing-source.md +0 -198
  56. package/docs/research/consumer-owned-protocol-files.md +0 -238
  57. package/docs/research/frontend-agent-benchmarks.md +0 -282
  58. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  59. package/docs/research/provider-neutral-agent-routing.md +0 -207
  60. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  61. package/docs/research/wave-43-script-hook-census.md +0 -128
  62. package/scripts/build-kit.mjs +0 -117
  63. package/scripts/build-kit.test.mjs +0 -207
  64. package/scripts/census/census.test.mjs +0 -252
  65. package/scripts/census/state.test.mjs +0 -15
  66. package/scripts/census/transaction.test.mjs +0 -103
  67. package/scripts/census-contract.test.mjs +0 -227
  68. package/scripts/check-kit-staleness.mjs +0 -45
  69. package/scripts/check-kit-staleness.test.mjs +0 -19
  70. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  71. package/scripts/codex-exec.test.mjs +0 -871
  72. package/scripts/grill-census-wiring-guard.mjs +0 -87
  73. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  74. package/scripts/kit-release.test.mjs +0 -273
  75. package/scripts/kit-update-pr.test.mjs +0 -151
  76. package/scripts/lib/audit-refs.mjs +0 -146
  77. package/scripts/lib/scrub.mjs +0 -113
  78. package/scripts/lib/scrub.test.mjs +0 -187
  79. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  80. package/scripts/portability_profile_scan.py +0 -53
  81. package/scripts/release-delta-guard.test.mjs +0 -174
  82. package/scripts/release-parity.test.mjs +0 -90
  83. package/scripts/release-state.test.mjs +0 -360
  84. package/scripts/test_anchor_table.py +0 -69
  85. package/scripts/test_board_bootstrap.py +0 -348
  86. package/scripts/test_board_sync.py +0 -208
  87. package/scripts/test_board_sync_create_idempotency.py +0 -117
  88. package/scripts/test_board_sync_wave_title.py +0 -120
  89. package/scripts/test_census_backstop.py +0 -712
  90. package/scripts/test_census_forward_contract.py +0 -91
  91. package/scripts/test_census_update_contract.test.mjs +0 -454
  92. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  93. package/scripts/test_dist_kit_smoke.py +0 -220
  94. package/scripts/test_drift_guard_diagnostics.py +0 -295
  95. package/scripts/test_issue_claim_contract.py +0 -169
  96. package/scripts/test_kit_docs_language_census.py +0 -178
  97. package/scripts/test_marker_lib.py +0 -162
  98. package/scripts/test_orchestrate_wave_contract.py +0 -381
  99. package/scripts/test_pr_body_check.py +0 -264
  100. package/scripts/test_profile_globs.py +0 -280
  101. package/scripts/test_program_planning_contract.py +0 -133
  102. package/scripts/test_release_authorization_contract.py +0 -101
  103. package/scripts/test_render_anchor.py +0 -267
  104. package/scripts/test_retro_wrapup_contract.py +0 -153
  105. package/scripts/test_skill_code_review_seed.py +0 -89
  106. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  107. package/scripts/test_skill_frontmatter_lint.py +0 -234
  108. package/scripts/test_skill_gh_lint.py +0 -124
  109. package/scripts/test_skill_language_census.py +0 -254
  110. package/scripts/test_skill_optional_readiness.py +0 -171
  111. package/scripts/test_skill_portability_lint.py +0 -866
  112. package/scripts/test_skill_precommit_template.py +0 -30
  113. package/scripts/test_skill_publish_audit.py +0 -358
  114. package/scripts/test_skill_readiness_contract.py +0 -264
  115. package/scripts/test_skill_readiness_preflight.py +0 -180
  116. package/scripts/test_skill_required_readiness.py +0 -233
  117. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  118. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  119. package/scripts/test_skill_stale_name_lint.py +0 -126
  120. package/scripts/test_skill_surface_refs.py +0 -300
  121. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  122. package/scripts/test_tdd_contract.py +0 -78
  123. package/scripts/test_worktree_ignore_seed.py +0 -320
  124. package/scripts/test_worktree_setup_base_guard.py +0 -140
  125. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  126. package/scripts/test_wrapup_land.py +0 -428
  127. 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