claude-dev-env 1.49.0 → 1.49.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 (31) hide show
  1. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +72 -0
  2. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +36 -0
  3. package/audit-rubrics/category_rubrics/category-c-resource-cleanup.md +35 -0
  4. package/audit-rubrics/category_rubrics/category-d-scoping-and-ordering.md +35 -0
  5. package/audit-rubrics/category_rubrics/category-e-dead-code.md +38 -0
  6. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +38 -0
  7. package/audit-rubrics/category_rubrics/category-g-bounds-and-overflow.md +38 -0
  8. package/audit-rubrics/category_rubrics/category-h-security-boundaries.md +40 -0
  9. package/audit-rubrics/category_rubrics/category-i-concurrency.md +38 -0
  10. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +46 -0
  11. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +59 -0
  12. package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +45 -0
  13. package/audit-rubrics/category_rubrics/category-m-producer-consumer-cardinality.md +44 -0
  14. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +45 -0
  15. package/audit-rubrics/prompts/category-a-api-contracts.md +384 -0
  16. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +401 -0
  17. package/audit-rubrics/prompts/category-c-resource-cleanup.md +420 -0
  18. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +414 -0
  19. package/audit-rubrics/prompts/category-e-dead-code.md +420 -0
  20. package/audit-rubrics/prompts/category-f-silent-failures.md +420 -0
  21. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +383 -0
  22. package/audit-rubrics/prompts/category-h-security-boundaries.md +423 -0
  23. package/audit-rubrics/prompts/category-i-concurrency.md +429 -0
  24. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +463 -0
  25. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +328 -0
  26. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +128 -0
  27. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +129 -0
  28. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +132 -0
  29. package/audit-rubrics/source-material-section-types.md +51 -0
  30. package/package.json +2 -1
  31. package/skills/bugteam/reference/teardown-publish-permissions.md +7 -2
@@ -0,0 +1,45 @@
1
+ # Category N — Test-name scenario verifier
2
+
3
+ **What this category audits:** tests whose names assert a specific scenario (`test_*_when_*`, `test_*_at_*`, `test_*_under_*`, `test_*_with_*`) must demonstrate via fixture inspection, monkeypatch state, or environment setup that the named condition is in effect when the system under test runs. Common failure: a test named `test_resolves_under_unc_prefix` constructs a regular drive-letter path, asserts the function returns a non-empty result, and passes — but the UNC-prefix branch was never exercised. The test is a load-bearing witness that the named scenario works, but it actually only exercises the no-op default path.
4
+
5
+ **Why this category is its own bucket:** Categories A–K catch defects in the production code. Category N catches defects in the *test corpus's claim to coverage*. A green test suite is only meaningful when each test's name accurately describes what was exercised. When a test passes "for the wrong reason" — e.g., the assertion matches a substring that appears in both the scenario-named branch AND the default branch — the suite reports coverage of a scenario it never actually ran.
6
+
7
+ **Examples of Category N findings:**
8
+ - Cross-platform tests named `test_*_on_windows` / `test_*_on_linux` that don't gate on `sys.platform` or `monkeypatch.setattr(os, "name", ...)` and run unchanged on every platform. (ccc#476 F5, F21, F23, F26, F27)
9
+ - String-shape tests that exercise only the no-op branch (`assert result == ""` after constructing an input that hits the early-return path, not the named scenario). (pa#135 F11, F15)
10
+ - Integration tests with assertions like `<substring> not in executed_sql` where the substring shape never matches the SQL fragment shape — the assertion cannot fail by construction. (pa#136 F50)
11
+ - Path-decision tests for `is_test_file` / `is_hook_infrastructure` / `_resolve_*_path` without a parametric matrix of canonical edge cases (empty string, tilde-prefix, UNC, drive-letter, symlinked, `..`-containing, trailing slash).
12
+
13
+ **Companion reference:** see `../source-material-section-types.md`.
14
+
15
+ ---
16
+
17
+ ## Sub-bucket decomposition (Category N)
18
+
19
+ Decomposition is by the **kind of scenario claim** the test name makes vs the evidence in its body.
20
+
21
+ | ID | Axis name | Concrete checks |
22
+ |---|---|---|
23
+ | N1 | Scenario-named tests demonstrate the scenario | A test whose name contains `_when_`, `_at_`, `_under_`, `_with_`, `_on_`, `_after_`, `_during_` proves via monkeypatch / fixture / environment setup that the named condition holds during the act phase |
24
+ | N2 | Path-decision parametric matrices | Tests for `is_*_path` / `_resolve_*_path` / `*_path_exemptions` modules ship a parametrized matrix covering: empty string, single filename, tilde-prefix, UNC path, drive-letter path, symlinked path, `..`-containing path, trailing-slash path |
25
+ | N3 | Tests that pass "for the wrong reason" | The assertion's substring matches both the scenario-named branch and the default branch — the test cannot distinguish which branch executed |
26
+ | N4 | No-op branch exercised by scenario name | The constructed input hits an early-return / guard / no-feature-installed branch BEFORE the scenario-named code runs; the assertion succeeds against the no-op output |
27
+ | N5 | Assertion shape mismatch | The assertion shape (`<substring> not in result`, `result == ""`, `len(result) > 0`) cannot fail by construction against the actual return type / value space |
28
+ | N6 | Cross-platform scenario gating | Tests named `_on_<platform>` MUST gate on `sys.platform`, `monkeypatch.setattr(os, ...)`, or `@pytest.mark.skipif(sys.platform != "<platform>")` — bare scenario names that run unchanged across platforms claim more than they prove |
29
+ | N7 | Time / clock scenario gating | Tests named `_after_<duration>` / `_at_midnight` / `_during_business_hours` MUST inject a frozen clock (`freezegun.freeze_time`, `monkeypatch.setattr(time, "time", ...)`) — wall-clock tests are non-deterministic and may pass against the wrong scenario |
30
+ | N8 | Concurrent / load scenario gating | Tests named `_under_load` / `_with_concurrent_writers` MUST spawn the concurrent workers and `wait()` on them — single-threaded tests cannot claim concurrent-scenario coverage |
31
+ | N9 | Neutral-named tests (out of scope) | Tests named `test_returns_empty_list_for_unknown_key` / `test_handles_y` (no scenario claim in the name) are NOT subject to N1–N8; flag them only for assertion-shape mismatches under N5 |
32
+
33
+ Customize per-artifact: a pure-function test corpus with no scenario claims reduces N1–N4 to "verified clean — no scenario-named tests in scope"; a path-classifier PR may need N2 exhausted across 8+ canonical inputs.
34
+
35
+ ---
36
+
37
+ ## Sample prompt
38
+
39
+ The reusable Variant C template for Category N is in [`../prompts/category-n-test-name-scenario-verifier.md`](../prompts/category-n-test-name-scenario-verifier.md). Inline every changed test function under `## Source material` along with the production function it claims to cover, so the audit can compare the named scenario against the body's act phase.
40
+
41
+ ## Why Category N matters as its own bucket
42
+
43
+ Categories A–K each examine production code. Category N examines whether the test corpus's name claims match its body evidence. A green suite without N coverage gives a false sense of confidence: scenario-named tests that pass against the no-op branch report success for code that was never run.
44
+
45
+ The ccc#476 cases (F5, F21, F23, F26, F27) demonstrate the cost of not running N: tests named `_on_windows` and `_on_linux` ran unchanged on every CI runner. The Windows-specific test passed on Linux because the production function's Windows branch was guarded by `sys.platform == "win32"` and short-circuited — the assertion succeeded against the Linux fallback. The test name claimed Windows coverage; the body never exercised it. Only an N6-style gating audit (require `monkeypatch.setattr(sys, "platform", "win32")` inside any `_on_windows`-named test, or `@pytest.mark.skipif`) would have caught this in review.
@@ -0,0 +1,384 @@
1
+ Audit [REPO/ARTIFACT] [TARGET_ID] for **Category A only** (API contract verification). Skip B–K. Sub-bucket forced-exhaustion mode: Category A is decomposed into 8 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
2
+
3
+ [ARTIFACT METADATA: title / change description / head SHA or revision identifier / scope summary]
4
+ ID prefix: `find`.
5
+
6
+ ## Source material ([N] files/sections, all lines in scope)
7
+
8
+ [INLINE THE FULL ARTIFACT HERE — see ../source-material-section-types.md for chunking guidance.]
9
+
10
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
11
+
12
+ **A1. Function/method signatures vs internal call sites**
13
+ - Enumerate every defined function or method's parameter list: count, names, defaults, kw-only barriers (Python `*`), positional-only barriers (`/`), variadic markers (`*args`, `**kwargs`).
14
+ - For every internal call within the artifact, verify the binding matches the callee's signature: positional count, keyword names, required-vs-optional, default fall-through.
15
+ - Flag positional arguments passed to keyword-only parameters and vice versa.
16
+ - Flag calls that omit a required parameter relying on a default that does not exist on the current branch.
17
+ - Verify decorators (`@staticmethod`, `@classmethod`, `@property`) do not silently shift the parameter binding (e.g., `self` / `cls` insertion).
18
+
19
+ **A2. Return-type annotation vs every code path**
20
+ - For each annotated function, walk every code path: explicit `return X`, fall-through to implicit `None`, exception-handler exit, generator `yield` paths, async coroutine return value.
21
+ - Verify each path's actual return value is assignable to the declared annotation; flag `-> bool` functions that can return `None`, `-> list[T]` functions that can return `None` on an early exit, etc.
22
+ - For functions that raise instead of returning on some path, confirm the annotation does not promise a value the caller will dereference.
23
+ - Inspect `try/except/finally` chains for paths that return from `finally` and override `try`/`except` returns.
24
+ - For async functions, confirm the annotation refers to the awaited type, not the coroutine wrapper.
25
+
26
+ **A3. CLI/argument-parser declaration → downstream Namespace contract**
27
+ - For every `add_argument(...)` (or equivalent CLI declaration), verify the auto-derived or explicit `dest=` matches the attribute name accessed downstream on the parsed namespace.
28
+ - Verify `type=` (or schema coercion) matches every downstream consumer's expectation — e.g., a value handed to a function requiring `int` is declared `type=int`, not the default `str`.
29
+ - Switch flags (`action="store_true"` / `store_false`) produce booleans; non-switch arguments produce typed values; flag the mismatch where a switch is treated as a value or vice versa.
30
+ - Default values resolve correctly when the flag is omitted; flag any code path that assumes the user supplied the argument.
31
+ - Required-vs-optional declaration matches the downstream code's null-handling.
32
+
33
+ **A4. Stdlib/library callback contracts**
34
+ - Identify every callback handed to a library function (e.g., `os.walk(onerror=...)`, sort `key=`, `filter`, `map`, `re.sub(repl=callable)`, signal handlers, threading callbacks). Verify each callback's signature matches what the library calls it with — arity, positional-vs-keyword, return type the library consumes.
35
+ - For every stdlib function the artifact calls, verify argument types and exception contracts: which exceptions can each call raise, and is each caller prepared (or deliberately not prepared) for them.
36
+ - Verify kwargs to stdlib functions are spelled correctly for the targeted runtime version (deprecated/renamed kwargs, version-introduced kwargs).
37
+ - Flag callbacks whose return value the library consumes but the implementation returns `None` (or vice versa).
38
+ - Confirm callback exception behavior: which exceptions in the callback bubble out, which are swallowed by the library, which terminate iteration.
39
+
40
+ **A5. Subprocess / external-process invocation contract**
41
+ - For every `subprocess.run` / `subprocess.Popen` / equivalent call, verify the `args` shape: list-of-strings vs single string vs `shell=True` semantics.
42
+ - Verify kwargs are valid for the targeted runtime version (`capture_output`, `text`, `encoding`, `check`, `timeout`); flag combinations that conflict (`stdout=PIPE` + `capture_output=True`).
43
+ - Exception contract under `check=True` (raises `CalledProcessError` on non-zero exit) — verify callers either propagate or handle, with no silent swallow that masks failure.
44
+ - Verify quoting/escaping of arguments crossing the subprocess boundary, especially when interpolating untrusted strings.
45
+ - Verify the resolved executable path is real on the target platform, not assumed (`which` / `Get-Command` failure paths).
46
+
47
+ **A6. Shell/host-language cmdlet or function parameter sets and binding**
48
+ - For every shell or host-language function/cmdlet declaration with parameter sets (PowerShell `param(...)` with `ParameterSetName=`, Bash `getopts`, etc.), verify the declaration matches every invocation pattern. Confirm a default parameter set is declared if no-arg invocation is reachable.
49
+ - For every cmdlet/builtin invocation, verify the parameter combination is valid per the cmdlet's documented parameter sets — flag combinations that mix flags from disjoint parameter sets.
50
+ - Flag missing `-ErrorAction` (or equivalent) declarations on calls whose null-checks downstream assume swallowed errors.
51
+ - Verify each cmdlet/builtin argument's type coercion at the boundary matches what the cmdlet expects.
52
+ - Confirm pipeline-bound parameters (`ValueFromPipeline`, `ValueFromPipelineByPropertyName`) match what upstream emits.
53
+
54
+ **A7. Cross-language / cross-process argv and serialization boundary**
55
+ - Trace every value crossing a language or process boundary (shell argv → Python `sys.argv`, environment variables, JSON/IPC payloads, file-format round-trips). Verify the producer's serialization matches the consumer's parser.
56
+ - Flag trailing-backslash, embedded-space, embedded-quote, and Unicode hazards on Windows argv composition (Microsoft C-runtime argv parser rules) and POSIX shell word-splitting.
57
+ - Verify argument-order conventions match across the boundary — e.g., flag order, positional placement, separator handling (`--`).
58
+ - Cross-language default-value drift: a default declared on one side that differs from the default on the other side; verify either both are sourced from a single config or both are intentionally mirrored.
59
+ - Cross-language type drift: integer width, signed/unsigned, floating-point precision, string encoding (UTF-8 vs UTF-16), null/empty-string semantics.
60
+
61
+ **A8. Documented API/tool calls vs official API documentation**
62
+ - For every API call, MCP tool invocation, CLI command, or SDK method call documented in the source material, identify the provider.
63
+ - Look up the official documentation for that API (Context7 MCP for libraries/SDKs, API reference docs for REST endpoints, tool definitions in session for MCP calls, `--help` for CLI tools).
64
+ - Verify the documented parameter names, types, and required-ness match the official API signature.
65
+ - For read-only API calls, execute one safe invocation to confirm the documented shape succeeds in practice.
66
+ - For write calls, verify the signature against the provider's own published API contract — their REST reference docs, OpenAPI spec, SDK source code, or `--help` output. When a read endpoint exposes the same state, call it to confirm the write contract.
67
+ - Flag every call where documented parameters, types, or behavior diverge from the official API contract.
68
+
69
+ ## Cross-bucket questions to answer at the end
70
+
71
+ Q1: Are there any contracts that span two sub-buckets that single-bucket analysis would miss?
72
+ Q2: What is the worst contract-drift hazard introduced by this artifact? Cite file:line.
73
+ Q3: Where would a future refactor most likely break a cross-bucket or cross-language contract? Name the line(s) most fragile.
74
+
75
+ ## Output
76
+
77
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket A1–A8, produce Shape A or Shape B (with ≥3 adversarial probes). Cross-bucket Q1–Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P1 bugs across these 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
78
+
79
+ ---
80
+
81
+ # Worked example: jl-cmd/claude-code-config PR #394 (May 2026 audit experiment)
82
+
83
+ Audit jl-cmd/claude-code-config PR #394 for **Category A only** (API contract verification). Skip B–J. Sub-bucket forced-exhaustion mode: Category A is decomposed into 8 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
84
+
85
+ PR: feat(scripts): add sweep-empty-dirs utility and scheduled-task installer
86
+ Head SHA: 62c9c169ee7a44824e5da25c4cf8b74fdca08a53
87
+ ID prefix: `find`.
88
+
89
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
90
+
91
+ **A1. Python function signatures vs internal call sites**
92
+ - Every defined function's parameter list (count, names, defaults, kw-only).
93
+ - Every internal call within `sweep_empty_dirs.py` matches its target's signature.
94
+
95
+ **A2. Python return-type annotation vs every code path**
96
+ - Each function's return annotation is satisfied by every path: explicit `return X`, fall-through, exception-handler exit.
97
+ - Pay specific attention to `main() -> None` (with the `KeyboardInterrupt` path) and `sweep(...) -> list[str]` (single return path).
98
+
99
+ **A3. argparse parser → Namespace contract**
100
+ - Every `add_argument(...)` declared in `_build_parser()` produces the exact dest name accessed in `main()`.
101
+ - `type=` matches downstream usage (e.g., `time.sleep` accepts the type).
102
+ - Switch flag (`--once`) produces a bool; non-switches produce typed values.
103
+ - Default values resolve correctly when the flag is omitted.
104
+
105
+ **A4. stdlib callback contracts (os.walk onerror, getctime, rmdir)**
106
+ - `os.walk(root, onerror=_log_walk_error, topdown=False)` — `_log_walk_error` matches the exact signature stdlib calls (positional `OSError`).
107
+ - `os.path.getctime`, `os.rmdir` argument and exception contracts.
108
+ - `time.sleep` argument contract.
109
+ - `os.walk`'s own kwargs (`onerror`, `topdown`) are spelled correctly for the Python version targeted.
110
+
111
+ **A5. subprocess invocation contract (test file)**
112
+ - `subprocess.run([list], check=True, capture_output=True)` — kwargs valid for the targeted Python.
113
+ - The list shape — `["powershell", "-Command", string]` — matches `subprocess.run`'s expected `args` parameter.
114
+ - Exception contract: `check=True` raises CalledProcessError on non-zero exit; tests do not catch it.
115
+
116
+ **A6. PowerShell cmdlet parameter sets and binding**
117
+ - `param(...)` declarations with `ParameterSetName=` — does the script declare `[CmdletBinding(DefaultParameterSetName=...)]`? If not, what happens with no-arg invocation?
118
+ - `New-ScheduledTaskTrigger -Daily -At ... -RepetitionInterval ...` — is `-RepetitionInterval` valid for the `-Daily` parameter set per Microsoft's docs? Verify against https://learn.microsoft.com/powershell/module/scheduledtasks/new-scheduledtasktrigger.
119
+ - `Get-Command python` (line 80) — missing `-ErrorAction SilentlyContinue` in the fallback breaks the subsequent null-check contract.
120
+ - `Register-ScheduledTask`, `New-ScheduledTaskAction`, `New-ScheduledTaskSettingsSet`, `Get-ScheduledTask`, `Unregister-ScheduledTask` — verify each call's parameter shape against its cmdlet signature.
121
+
122
+ **A7. Cross-language argv boundary (PowerShell argv construction → Python sys.argv → argparse)**
123
+ - The `New-ScheduledTaskAction -Argument` string `"$ScriptPath --once --age $AgeSeconds ""$Target"""` — when expanded and passed to Windows process creation, does the resulting argv match what argparse expects?
124
+ - Trailing-backslash hazard: if `$Target` ends with `\`, what does the Microsoft C-runtime argv parser produce?
125
+ - The Python `argparse` flag order — does the launcher's argv arrangement (`<flags> <positional>`) match what argparse accepts?
126
+ - Cross-language default-value drift: PowerShell `[int]$AgeSeconds = 120` vs Python `DEFAULT_AGE_SECONDS: int = 120` — both hardcoded, no shared source of truth.
127
+ - Cross-language type drift: PowerShell `[int]` vs Python `type=int` — both convert to integer, but PowerShell's `[int]` is 32-bit signed; argparse's `int(...)` is Python int (arbitrary precision). Match for normal values.
128
+
129
+ ## Cross-bucket questions to answer at the end
130
+
131
+ Q1: Are there any contracts that span two sub-buckets that single-bucket analysis would miss?
132
+ Q2: What's the worst contract-drift hazard introduced by this PR? Cite file:line.
133
+ Q3: Where would a future refactor most likely break a cross-language contract? Name the line(s) most fragile.
134
+
135
+ ## Output
136
+
137
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket A1-A7, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 P1 bugs across these 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
138
+
139
+ ## Diff (4 new files, all lines in scope)
140
+
141
+ ### packages/claude-dev-env/scripts/sweep_empty_dirs.py
142
+ ```python
143
+ #!/usr/bin/env python3
144
+ """Delete empty directories older than 2 minutes under a given root."""
145
+
146
+ import argparse
147
+ import os
148
+ import sys
149
+ import time
150
+
151
+ from config.sweep_config import DEFAULT_AGE_SECONDS
152
+ from config.sweep_config import DEFAULT_POLL_INTERVAL
153
+
154
+
155
+ def _log_walk_error(os_error: OSError) -> None:
156
+ print(f"warning: cannot scan {os_error.filename} — {os_error.strerror}", file=sys.stderr)
157
+
158
+
159
+ def sweep(root: str, min_age_seconds: int) -> list[str]:
160
+ """Remove empty directories under *root* older than *min_age_seconds*."""
161
+
162
+ now = time.time()
163
+ removed: list[str] = []
164
+
165
+ for each_directory_path, _, _ in os.walk(
166
+ root, onerror=_log_walk_error, topdown=False
167
+ ):
168
+ try:
169
+ created = os.path.getctime(each_directory_path)
170
+ except OSError:
171
+ continue
172
+ if now - created >= min_age_seconds:
173
+ try:
174
+ os.rmdir(each_directory_path)
175
+ print(f"deleted: {each_directory_path}")
176
+ removed.append(each_directory_path)
177
+ except OSError:
178
+ pass
179
+
180
+ return removed
181
+
182
+
183
+ def _build_parser() -> argparse.ArgumentParser:
184
+ parser = argparse.ArgumentParser(description="Delete empty directories older than a given age.")
185
+ parser.add_argument("root", help="Root directory to scan")
186
+ parser.add_argument("--age", type=int, default=DEFAULT_AGE_SECONDS,
187
+ help=f"Minimum age in seconds (default: {DEFAULT_AGE_SECONDS} = 2 minutes)")
188
+ parser.add_argument("--once", action="store_true",
189
+ help="Single pass and exit instead of watching in a loop")
190
+ parser.add_argument("--interval", type=int, default=DEFAULT_POLL_INTERVAL,
191
+ help=f"Poll interval in seconds when looping (default: {DEFAULT_POLL_INTERVAL})")
192
+ return parser
193
+
194
+
195
+ def main() -> None:
196
+ parser = _build_parser()
197
+ arguments = parser.parse_args()
198
+
199
+ if not os.path.isdir(arguments.root):
200
+ print(f"error: not a directory: {arguments.root}", file=sys.stderr)
201
+ sys.exit(1)
202
+
203
+ if arguments.once:
204
+ sweep(arguments.root, arguments.age)
205
+ return
206
+
207
+ print(f"watching {arguments.root} every {arguments.interval}s (age threshold: {arguments.age}s)")
208
+ try:
209
+ while True:
210
+ sweep(arguments.root, arguments.age)
211
+ time.sleep(arguments.interval)
212
+ except KeyboardInterrupt:
213
+ print("\nstopped.")
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
218
+ ```
219
+
220
+ ### packages/claude-dev-env/scripts/config/sweep_config.py
221
+ ```python
222
+ """Centralized timing configuration for sweep_empty_dirs."""
223
+
224
+ DEFAULT_AGE_SECONDS: int = 120
225
+ DEFAULT_POLL_INTERVAL: int = 30
226
+ ```
227
+
228
+ ### packages/claude-dev-env/scripts/tests/test_sweep_empty_dirs.py
229
+ ```python
230
+ """Tests for sweep_empty_dirs.py"""
231
+
232
+ from __future__ import annotations
233
+
234
+ import datetime
235
+ import os
236
+ import subprocess
237
+ import sys
238
+ import tempfile
239
+ import time
240
+ from pathlib import Path
241
+
242
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
243
+ if str(_SCRIPTS_DIR) not in sys.path:
244
+ sys.path.insert(0, str(_SCRIPTS_DIR))
245
+
246
+ from sweep_empty_dirs import sweep # noqa: E402
247
+
248
+
249
+ def _set_creation_time_windows(path: str, timestamp: float) -> None:
250
+ dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
251
+ date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
252
+ subprocess.run(
253
+ ["powershell", "-Command",
254
+ f"(Get-Item '{path}').CreationTimeUtc = [DateTime]'{date_str}'"],
255
+ check=True, capture_output=True,
256
+ )
257
+
258
+
259
+ def test_deletes_empty_dir_older_than_threshold() -> None:
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ empty_dir = os.path.join(tmp, "old_empty")
262
+ os.mkdir(empty_dir)
263
+ _set_creation_time_windows(empty_dir, time.time() - 300)
264
+ removed = sweep(tmp, min_age_seconds=120)
265
+ assert empty_dir in removed
266
+ assert not os.path.isdir(empty_dir)
267
+
268
+
269
+ def test_skips_empty_dir_newer_than_threshold() -> None:
270
+ with tempfile.TemporaryDirectory() as tmp:
271
+ fresh_dir = os.path.join(tmp, "fresh_empty")
272
+ os.mkdir(fresh_dir)
273
+ removed = sweep(tmp, min_age_seconds=120)
274
+ assert fresh_dir not in removed
275
+ assert os.path.isdir(fresh_dir)
276
+
277
+
278
+ def test_deletes_nested_empty_dirs() -> None:
279
+ with tempfile.TemporaryDirectory() as tmp:
280
+ leaf = os.path.join(tmp, "parent", "child", "leaf")
281
+ os.makedirs(leaf)
282
+ _set_creation_time_windows(os.path.join(tmp, "parent"), time.time() - 300)
283
+ _set_creation_time_windows(os.path.join(tmp, "parent", "child"), time.time() - 300)
284
+ _set_creation_time_windows(leaf, time.time() - 300)
285
+ removed = sweep(tmp, min_age_seconds=120)
286
+ assert leaf in removed
287
+ assert os.path.join(tmp, "parent", "child") in removed
288
+ assert os.path.join(tmp, "parent") in removed
289
+
290
+
291
+ def test_empty_root_does_not_crash() -> None:
292
+ with tempfile.TemporaryDirectory() as tmp:
293
+ _set_creation_time_windows(tmp, time.time() - 300)
294
+ sweep(tmp, min_age_seconds=120)
295
+
296
+
297
+ def test_skips_nonempty_dir() -> None:
298
+ with tempfile.TemporaryDirectory() as tmp:
299
+ nonempty_dir = os.path.join(tmp, "has_stuff")
300
+ os.mkdir(nonempty_dir)
301
+ Path(nonempty_dir, "keepme.txt").write_text("hello")
302
+ removed = sweep(tmp, min_age_seconds=0)
303
+ assert nonempty_dir not in removed
304
+ assert os.path.isdir(nonempty_dir)
305
+ ```
306
+
307
+ ### packages/claude-dev-env/scripts/Install-SweepEmptyDirs.ps1
308
+ ```powershell
309
+ #!/usr/bin/env pwsh
310
+ param(
311
+ [Parameter(ParameterSetName = "install")]
312
+ [string]$Target,
313
+
314
+ [Parameter(ParameterSetName = "install")]
315
+ [int]$IntervalMinutes = 5,
316
+
317
+ [Parameter(ParameterSetName = "install")]
318
+ [int]$AgeSeconds = 120,
319
+
320
+ [Parameter(ParameterSetName = "remove")]
321
+ [switch]$Remove,
322
+
323
+ [Parameter(ParameterSetName = "status")]
324
+ [switch]$Status
325
+ )
326
+
327
+ $TaskName = "SweepEmptyDirs"
328
+
329
+ if ($Status) {
330
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
331
+ if (-not $task) {
332
+ Write-Host "STATUS: $TaskName is not registered."
333
+ return
334
+ }
335
+ Write-Host "STATUS: $TaskName is registered."
336
+ Write-Host " State: $($task.State)"
337
+ Write-Host " Actions:"
338
+ foreach ($action in $task.Actions) {
339
+ Write-Host " $($action.Execute) $($action.Arguments)"
340
+ }
341
+ Write-Host " Triggers:"
342
+ foreach ($trigger in $task.Triggers) {
343
+ Write-Host " $($trigger.Repetition.Interval) (starting $($trigger.StartBoundary))"
344
+ }
345
+ return
346
+ }
347
+
348
+ if ($Remove) {
349
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
350
+ Write-Host "$TaskName removed."
351
+ return
352
+ }
353
+
354
+ $ScriptDir = Split-Path -Parent $PSCommandPath
355
+ $ScriptPath = Join-Path $ScriptDir "sweep_empty_dirs.py"
356
+
357
+ if (-not (Test-Path $ScriptPath)) {
358
+ Write-Error "sweep_empty_dirs.py not found at: $ScriptPath"
359
+ exit 1
360
+ }
361
+
362
+ if (-not $Target) {
363
+ Write-Error "Parameter -Target is required (the directory to watch)."
364
+ exit 1
365
+ }
366
+
367
+ if (-not (Test-Path $Target)) {
368
+ Write-Error "Target directory does not exist: $Target"
369
+ exit 1
370
+ }
371
+
372
+ $_py = Get-Command py -ErrorAction SilentlyContinue
373
+ $PythonPath = if ($_py) { $_py.Source } else { (Get-Command python).Source }
374
+ if (-not $PythonPath) {
375
+ Write-Error "Cannot find Python (py or python) on PATH."
376
+ exit 1
377
+ }
378
+ $Action = New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath --once --age $AgeSeconds ""$Target"""
379
+ $Trigger = New-ScheduledTaskTrigger -Daily -At "00:00" -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
380
+ $Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
381
+
382
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Force | Out-Null
383
+ Write-Host "$TaskName registered — runs every ${IntervalMinutes}min against '$Target' (age ≥ ${AgeSeconds}s)."
384
+ ```