claude-dev-env 1.49.0 → 1.50.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 (43) hide show
  1. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +86 -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 +399 -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/docs/CODE_RULES.md +6 -1
  31. package/hooks/blocking/code_rules_enforcer.py +323 -11
  32. package/hooks/blocking/md_to_html_blocker.py +2 -2
  33. package/hooks/blocking/test_code_rules_enforcer.py +65 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
  35. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
  37. package/hooks/blocking/test_md_to_html_blocker.py +38 -0
  38. package/hooks/hooks_constants/blocking_check_limits.py +2 -0
  39. package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
  40. package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
  41. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
  42. package/package.json +2 -1
  43. package/skills/bugteam/reference/teardown-publish-permissions.md +7 -2
@@ -0,0 +1,414 @@
1
+ Audit [REPO/ARTIFACT] [TARGET_ID] for **Category D only** (variable scoping, ordering, and unbound references). Skip A–C, E–K. Sub-bucket forced-exhaustion mode: Category D 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]
4
+ - Repo / artifact: [REPO_OR_ARTIFACT]
5
+ - Target ID: [TARGET_ID] (e.g., PR number, commit SHA, file path, document name)
6
+ - Head SHA / revision: [HEAD_SHA_OR_REVISION]
7
+ - Title / summary: [TITLE]
8
+ - Files / sections in scope: [LIST_OF_PATHS_OR_SECTIONS]
9
+
10
+ ID prefix: `find`.
11
+
12
+ Line-number convention: every `:N` reference points to the file-relative line number of the file inlined in `## Source material` further down. A line citation is verifiable iff the cited number is present in the corresponding file fence below.
13
+
14
+ ## Source material
15
+
16
+ Inline the artifact under audit using one `###` header per section. Pick the chunk size per the [chunking guide](../source-material-section-types.md): one file per section for code PRs, one function/class per section for long single-module audits, one named heading per section for design docs, etc. Keep section anchors stable and copy-pasteable so findings can cite `<section>:<line>` unambiguously.
17
+
18
+ ```
19
+ ## Source material ([N] sections, all lines in scope)
20
+
21
+ ### [section-1-anchor]
22
+ ```[language]
23
+ [content]
24
+ ```
25
+
26
+ ### [section-2-anchor]
27
+ ```[language]
28
+ [content]
29
+ ```
30
+ ```
31
+
32
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
33
+
34
+ **D1. Variable referenced before assignment on a branch**
35
+ - Identify every name that is assigned on only some branches of an `if`/`elif`/`else`, `try`/`except`/`else`/`finally`, or `match` block, then read after the block. Cite each binding site and each read site by `<section>:<line>`.
36
+ - For each `try:` block whose `except` arm uses `continue` / `return` / `raise` / `sys.exit` to short-circuit, prove the read site is unreachable on the failure path. Replace `continue` mentally with `pass` and check whether the next read becomes unbound — that is the adversarial probe.
37
+ - Walk every loop, comprehension, and generator and confirm names that are only bound conditionally inside the loop body are not read after the loop terminates with zero iterations (e.g., `for x in xs: ...` followed by `return x` when `xs` may be empty).
38
+ - Confirm parameters and locals introduced at the top of a function are bound on every code path that reaches their first read (early-return guards, exception handlers, parser-driven `SystemExit`).
39
+
40
+ **D2. Loop closure capture (by-ref vs by-value)**
41
+ - Walk every `for` / `while` loop body and list any `lambda`, nested `def`, list/dict/set/generator comprehension that defers evaluation, `functools.partial`, `asyncio.create_task`, `threading.Thread`, `multiprocessing` worker, or `concurrent.futures.submit` that closes over the loop variable.
42
+ - For language-specific late-binding hazards (Python `lambda` capturing by reference, JavaScript `var` in a `for` loop, PowerShell `ForEach-Object { ... }` script blocks vs `foreach` statement), state which language semantics apply and probe whether the captured name is consumed in the same iteration or stored for later.
43
+ - Confirm callbacks registered with event loops, signal handlers, or scheduler hooks do not retain stale references to per-iteration state.
44
+
45
+ **D3. Name shadowing of outer-scope symbols**
46
+ - Enumerate every parameter and local name introduced in each function, then check it against (a) language builtins, (b) module-level imports, (c) class-level attributes still in use within the function. Cite each candidate by `<section>:<line>`.
47
+ - For names that are intentionally similar to imported modules (e.g., a parameter named `path` in a file that also imports `pathlib.Path`), confirm the function body resolves the imported symbol correctly at every call site.
48
+ - Probe loop / comprehension variables that share a name with an outer-scope symbol the surrounding function still relies on after the loop.
49
+ - For shell / scripting languages with implicit pipeline variables (PowerShell `$_`, Bash `$_`, `$@`), verify locally introduced names do not collide with those automatic variables.
50
+
51
+ **D4. Conditional definition leaving a symbol undefined**
52
+ - Find every `try: import X / except ImportError:` block, `if sys.platform == "..."` guard, version-conditional fallback, or feature-flag gate that binds a symbol on only some configurations. For each, cite the binding line and every read site that may execute when the guard is false.
53
+ - For installer / orchestration scripts that define variables only on one parameter-set branch (e.g., PowerShell `param(...)` sets, argparse subcommands), confirm every read site is reachable only from a branch where the variable was bound. Trace from each early `return` / `exit` upward.
54
+ - Confirm there are no platform-conditional `def` statements that leave a function name unbound on the non-matching platform.
55
+
56
+ **D5. Mutable default arguments**
57
+ - Walk every `def` (and language-equivalent: JavaScript default parameters with object/array literals, Ruby keyword defaults, etc.) and confirm no parameter has a mutable literal as its default — `[]`, `{}`, `set()`, `OrderedDict()`, `dict()` with no args, custom dataclass instances.
58
+ - For each `def` with a default argument, state whether the default is immutable (numeric, string, `None`, `tuple()`, `frozenset()`) or potentially mutable. Cite each by `<section>:<line>`.
59
+ - Probe-of-absence: state the count "0 across all [N] sections" and list every `def` walked.
60
+
61
+ **D6. Module-level circular imports / load order**
62
+ - For each module imported by the artifact under audit, confirm the import graph has no cycle that could leave a symbol partially bound. Cite every `from X import Y` line.
63
+ - Check for runtime `sys.path` mutations or `importlib` calls that occur after a top-level `from ... import`. Confirm the sequence cannot leave a name unbound (the `from` either succeeds and binds the name or raises and aborts module load).
64
+ - Identify any import-time side effects (top-level function calls, decorator-driven registration, `__init_subclass__` hooks) that depend on partial-module state.
65
+
66
+ **D7. Async/sync ordering of side effects**
67
+ - Scan the entire artifact for `async def`, `await`, `asyncio.gather` / `asyncio.create_task` / `asyncio.run`, JavaScript `async` / `await` / `Promise.all`, or any other deferred-execution primitive. If any are present, walk the ordering of side effects.
68
+ - For each `await` site, identify whether a side effect that should have happened *before* the suspension point is actually flushed before yielding control. Probe what an interleaved coroutine could observe.
69
+ - For purely synchronous artifacts, cite proof-of-absence with explicit keyword counts ("0 occurrences of `async`, `await`, `asyncio` across all [N] sections").
70
+
71
+ **D8. Class-attribute vs instance-attribute confusion**
72
+ - For every `class` definition, list each attribute introduced in the class body (class attribute) versus inside `__init__` / `__post_init__` / a factory classmethod (instance attribute). Cite each by `<section>:<line>`.
73
+ - For each method, walk every `cls.x` and `self.x` access and confirm the access kind matches the attribute's binding site. Probe whether mutation of a class-level mutable (e.g., `cls.cache = {}` shared across instances) is intended or accidental.
74
+ - For artifacts with no `class` definitions, cite proof-of-absence by scanning every section for the `class ` keyword and stating the count.
75
+
76
+ ## Cross-bucket questions to answer at the end
77
+
78
+ Q1: Is there any sub-bucket overlap — i.e., a single line that triggers more than one of D1–D8 simultaneously (e.g., a name that is both shadowed AND only conditionally bound)? Cite every overlapping site by `<section>:<line>` for each bucket it implicates.
79
+
80
+ Q2: What is the worst unbound-reference hazard a future refactor could introduce by editing the highest-risk loop or branch in the artifact? Name the specific line and the minimal one-token change (e.g., replacing `continue` with `pass`, moving a check above its `try:`, extracting a nested helper) that would convert a currently-clean call into an unbound-name error.
81
+
82
+ Q3: Among all variables read in the artifact's main entry point or top-level function, which one's binding context is most fragile to the addition of a new flag, parameter, or conditional branch — i.e., which one would silently become read-before-assigned if a maintainer wrapped its assignment in a new `if`? Name the line and the hypothetical branch.
83
+
84
+ ## Output
85
+
86
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket D1–D8, 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 scoping bugs across these 8 sub-buckets — find them." Open Questions section for ambiguities. Severity quota for the adversarial-pass minimum: P1. Read-only. No edits, no commits.
87
+
88
+ ---
89
+
90
+ # Worked example: jl-cmd/claude-code-config PR #394
91
+
92
+ Audit jl-cmd/claude-code-config PR #394 for **Category D only** (variable scoping, ordering, and unbound references). Skip A–C, E–K. Sub-bucket forced-exhaustion mode: Category D 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.
93
+
94
+ PR: feat(scripts): add sweep-empty-dirs utility and scheduled-task installer
95
+ Head SHA: 62c9c169ee7a44824e5da25c4cf8b74fdca08a53
96
+ ID prefix: `find`.
97
+
98
+ Line-number convention: every `:N` reference below points to the file-relative line number of the file inlined in `## Diff (4 new files, all lines in scope)` further down. The four files in scope are `packages/claude-dev-env/scripts/sweep_empty_dirs.py`, `packages/claude-dev-env/scripts/config/sweep_config.py`, `packages/claude-dev-env/scripts/tests/test_sweep_empty_dirs.py`, and `packages/claude-dev-env/scripts/Install-SweepEmptyDirs.ps1`. A line citation in this prompt is verifiable iff the cited number is present in the corresponding file fence below.
99
+
100
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
101
+
102
+ **D1. Variable referenced before assignment on a branch** ⭐ canonical D case for this PR
103
+ - The block at `sweep_empty_dirs.py:26-36` assigns `created` only inside `try:` (line 27, `created = os.path.getctime(each_directory_path)`) and uses it at `sweep_empty_dirs.py:30` (`if now - created >= min_age_seconds:`). The `except OSError:` arm at lines 28-29 calls `continue`, which skips the rest of the loop body. Verify the control-flow claim explicitly: when `os.path.getctime` raises, does control re-enter line 30 with `created` unbound, or does `continue` short-circuit to the next `os.walk` iteration?
104
+ - The `except OSError: continue` arm on line 28-29 is the only path on which `created` could be unbound at line 30. If the loop body were ever reordered so the `if` ran before the `except` handler, or if `continue` were replaced with `pass` (or `return None`, or a `log()` call with no jump), line 30 would read an unbound name. Note this in the proof and cite the two lines (28, 30) that make `continue` load-bearing.
105
+ - Inside the inner `try:` at `sweep_empty_dirs.py:31-36`, no name introduced on line 32-34 (`os.rmdir(...)`, the literal `f"deleted: ..."` string, `removed.append(...)`) is read after the `except OSError: pass` arm, so the inner block has no D1 hazard. State this explicitly.
106
+ - Verify `now` (`sweep_empty_dirs.py:20`), `removed` (`sweep_empty_dirs.py:21`), and the loop variable `each_directory_path` (`sweep_empty_dirs.py:23`) are each bound before every site that reads them. `removed` is read at line 38 (`return removed`) — confirm the loop never raises out without binding `removed` (it is bound before the loop on line 21, so this is safe).
107
+ - In `main()` (`sweep_empty_dirs.py:53-71`), the variable `arguments` (line 55) is read on lines 57, 58, 61, 62, 65, 68 — confirm `parse_args()` either returns or raises (`SystemExit`); it cannot leave `arguments` unbound at any read site. State this explicitly.
108
+
109
+ **D2. Loop closure capture (by-ref vs by-value)**
110
+ - The only loop in production code is `sweep_empty_dirs.py:23-36` (`for each_directory_path, _, _ in os.walk(...)`). Walk every line inside that loop body and confirm: no `lambda` keyword, no nested `def`, no `asyncio.create_task` / `threading.Thread` / `multiprocessing` / `concurrent.futures.submit`, no list/dict/set comprehension that defers evaluation, no `functools.partial` capturing `each_directory_path`. The body only calls module-level functions (`os.path.getctime`, `os.rmdir`, `print`, `removed.append`) directly, so `each_directory_path` is consumed in the same iteration it is bound.
111
+ - The PowerShell `foreach` loops at `Install-SweepEmptyDirs.ps1:30-32` and `Install-SweepEmptyDirs.ps1:34-36` iterate over `$task.Actions` and `$task.Triggers` and only call `Write-Host` with the current iteration's variable inside `$(...)` subexpressions. PowerShell `foreach` binds the iterator variable by value to the local scope on each iteration, and there are no script blocks (`{ ... }` passed to `ForEach-Object` or stored as `[scriptblock]`) that would defer evaluation. Confirm by walking lines 30, 31, 32, 34, 35, 36.
112
+ - The `while True:` loop at `sweep_empty_dirs.py:67-69` has no nested closures or deferred callbacks; it only calls `sweep(...)` and `time.sleep(...)` synchronously. The `try:`/`except KeyboardInterrupt:` (lines 66-71) wraps the whole loop, so no callback is registered with the event loop or signal handler.
113
+
114
+ **D3. Name shadowing of outer-scope symbols**
115
+ - Walk every parameter and local name introduced in `sweep_empty_dirs.py` and check against Python builtins and module-level imports (`argparse`, `os`, `sys`, `time`, `DEFAULT_AGE_SECONDS`, `DEFAULT_POLL_INTERVAL`):
116
+ - `os_error: OSError` (`sweep_empty_dirs.py:13`) — does NOT shadow the imported `os` module (the suffix `_error` disambiguates). Confirm by checking that `os.path.getctime` (line 27), `os.walk` (line 23), `os.rmdir` (line 32), `os.path.isdir` (line 57) all still resolve to the module.
117
+ - `root: str` (`sweep_empty_dirs.py:17`, also used as `arguments.root` on lines 57, 58, 62, 65, 68) — not a builtin; not an imported symbol. Safe.
118
+ - `min_age_seconds: int` (`sweep_empty_dirs.py:17`) — not a builtin; not an imported symbol. Safe.
119
+ - `now` (`sweep_empty_dirs.py:20`) — not a builtin (`time.time` is the source; `now` is a local name). Safe.
120
+ - `removed: list[str]` (`sweep_empty_dirs.py:21`) — not a builtin; safe.
121
+ - `each_directory_path` (`sweep_empty_dirs.py:23`) — loop variable; not a builtin; safe.
122
+ - `created` (`sweep_empty_dirs.py:27`) — not a builtin; safe.
123
+ - `parser` (`sweep_empty_dirs.py:42`, `sweep_empty_dirs.py:54`) — not a builtin; the local in `main()` shadows nothing (`_build_parser` returns a fresh object). Safe.
124
+ - `arguments` (`sweep_empty_dirs.py:55`) — not a builtin; not an imported symbol. Safe.
125
+ - Verify that the test file's local names (`tmp`, `empty_dir`, `fresh_dir`, `leaf`, `nonempty_dir`, `removed`, `path`, `timestamp`, `dt`, `date_str`) at `test_sweep_empty_dirs.py:31-76` do not shadow any of the imports `datetime`, `os`, `subprocess`, `sys`, `tempfile`, `time`, `Path`. In particular, `path: str` (line 21) is a parameter name and is shadowed-by-design — confirm the function body (lines 22-28) never references the imported `Path` (it doesn't; it uses `subprocess.run` only).
126
+ - Verify the `date_str` (`test_sweep_empty_dirs.py:23`) and `dt` (line 22) names are short and do NOT collide with stdlib types (`datetime.datetime` is referenced via the imported `datetime` module on line 22, not via a local `datetime` rebind). State whether `datetime` is rebound anywhere in the file (it is not).
127
+ - The PowerShell variable `$_py` (`Install-SweepEmptyDirs.ps1:64`) does NOT shadow the automatic variable `$_` (PowerShell's pipeline current-object variable). PowerShell distinguishes `$_` from `$_py` — confirm by reading lines 64-65 and noting that no pipeline expression on those lines relies on `$_`.
128
+
129
+ **D4. Conditional definition leaving a symbol undefined**
130
+ - `sweep_empty_dirs.py` has zero `try: import X / except ImportError:` blocks; every import (lines 4-7, 9-10) is unconditional and at module top.
131
+ - `sweep_empty_dirs.py` has zero `if sys.platform == "..."` guards. The script's Windows-only behavior (Windows-style creation timestamps, scheduled-task helper) is implicit, not gated. State explicitly that no symbol is platform-conditionally bound.
132
+ - The `_set_creation_time_windows` helper (`test_sweep_empty_dirs.py:21-28`) is unconditionally defined and unconditionally called from every test function (`test_sweep_empty_dirs.py:35, 54, 55, 56, 65`). It is NOT wrapped in `if sys.platform == "win32":` — but the audit treats that as a cross-cutting concern (test will fail on non-Windows due to `subprocess.run(["powershell", ...])`), not as a D4 *unbound-name* hazard.
133
+ - `Install-SweepEmptyDirs.ps1` defines `$ScriptDir`, `$ScriptPath`, `$_py`, `$PythonPath`, `$Action`, `$Trigger`, `$Settings` (lines 46-72) only on the install path (when `$Status` and `$Remove` are both falsy — both early-return on lines 25, 37, 43). The `Register-ScheduledTask` line at `Install-SweepEmptyDirs.ps1:74` reads `$Action`, `$Trigger`, `$Settings` — confirm these reach line 74 only when the `$Status` and `$Remove` early returns at lines 25, 37, 43 did not fire.
134
+
135
+ **D5. Mutable default arguments**
136
+ - `sweep_empty_dirs.py` has zero functions with mutable defaults. Walk each `def`:
137
+ - `_log_walk_error(os_error: OSError)` (`sweep_empty_dirs.py:13`) — no defaults.
138
+ - `sweep(root: str, min_age_seconds: int)` (`sweep_empty_dirs.py:17`) — no defaults.
139
+ - `_build_parser()` (`sweep_empty_dirs.py:41`) — no defaults.
140
+ - `main()` (`sweep_empty_dirs.py:53`) — no defaults.
141
+ - `test_sweep_empty_dirs.py` has zero functions with mutable defaults. Walk each `def`:
142
+ - `_set_creation_time_windows(path: str, timestamp: float)` (`test_sweep_empty_dirs.py:21`) — both defaults absent.
143
+ - The five `test_*` functions (lines 31, 41, 50, 63, 69) — all parameterless.
144
+ - Confirm there are zero `def f(... = [])`, `def f(... = {})`, `def f(... = set())`, `def f(... = OrderedDict())` constructs in the entire diff. State the proof-of-absence with the count "0 across all four files".
145
+
146
+ **D6. Module-level circular imports / load order**
147
+ - `sweep_empty_dirs.py` imports from `config.sweep_config` (lines 9-10). `config/sweep_config.py` defines two module-level constants (`DEFAULT_AGE_SECONDS`, `DEFAULT_POLL_INTERVAL`) and imports nothing. Confirm there is no `from sweep_empty_dirs import X` anywhere in `config/sweep_config.py` — i.e., no cycle.
148
+ - `test_sweep_empty_dirs.py` does a runtime `sys.path` mutation (`test_sweep_empty_dirs.py:14-16`) and then imports `from sweep_empty_dirs import sweep` at line 18. Confirm this sequence cannot leave `sweep` unbound: the `sys.path.insert` at line 16 is inside an `if` (line 15) but the `from ... import` at line 18 is unconditional, so the import either succeeds (binds `sweep`) or raises (test collection fails loudly). No partial-binding hazard.
149
+ - `sweep_empty_dirs.py` has zero import-time side effects (no top-level function calls beyond `def`/`from`/`import`). The entry point at `sweep_empty_dirs.py:74-75` is the standard `if __name__ == "__main__": main()` guard, which is not a load-order hazard.
150
+
151
+ **D7. Async/sync ordering of side effects**
152
+ - This PR contains zero `async def` definitions, zero `await` expressions, zero `asyncio.gather` / `asyncio.create_task` / `asyncio.run` calls. Confirm by scanning all four files for the keywords `async`, `await`, `asyncio`. Cite proof-of-absence.
153
+ - The synchronous loop at `sweep_empty_dirs.py:67-69` performs side effects (filesystem deletions inside `sweep`, then `time.sleep`) in straight-line order. There is no event-loop interleaving and no concurrent task that could observe an intermediate state.
154
+ - The PowerShell installer is also entirely synchronous; `New-ScheduledTaskAction`, `New-ScheduledTaskTrigger`, `New-ScheduledTaskSettingsSet`, `Register-ScheduledTask` (lines 70-74) execute in declared order. No `Start-Job`, no `Start-ThreadJob`, no `-AsJob` flag.
155
+
156
+ **D8. Class-attribute vs instance-attribute confusion**
157
+ - This PR contains zero `class` definitions across all four files. Confirm by scanning each file for the `class ` keyword (Python) and `class { ... }` blocks (PowerShell). Cite proof-of-absence — no `cls.x` / `self.x` / `__init__` / class-body assignments exist, so D8 is structurally inapplicable to this artifact.
158
+
159
+ ## Cross-bucket questions to answer at the end
160
+
161
+ Q1: Does the `try: created = os.path.getctime(...) / except OSError: continue` block at `sweep_empty_dirs.py:26-30` carry any sub-bucket overlap (e.g., is `created` *also* shadowing a wider-scope name that would be read on the `continue` path)? Cite both the D1 site and the D3 site if so.
162
+ Q2: What's the worst unbound-reference hazard a future refactor could introduce by editing the loop body at `sweep_empty_dirs.py:23-38`? Name the line that, if changed (e.g., replacing `continue` with `pass`, or moving the `if now - created` check above the `try:`, or extracting a nested helper), would convert a currently-clean call into an `UnboundLocalError`.
163
+ Q3: Among the variables read in `main()` (`sweep_empty_dirs.py:53-71`), which one's binding context is most fragile to the addition of a new `argparse` flag or a new conditional branch — i.e., which one would silently become read-before-assigned if a maintainer wrapped its assignment in a new `if`? Name the line and the hypothetical branch.
164
+
165
+ ## Output
166
+
167
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket D1–D8, 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 scoping bugs across these 8 sub-buckets — find them." Open Questions section for ambiguities. Severity quota for the adversarial-pass minimum: P1. Read-only. No edits, no commits.
168
+
169
+ ## Diff (4 new files, all lines in scope)
170
+
171
+ ### packages/claude-dev-env/scripts/sweep_empty_dirs.py
172
+ ```python
173
+ #!/usr/bin/env python3
174
+ """Delete empty directories older than 2 minutes under a given root."""
175
+
176
+ import argparse
177
+ import os
178
+ import sys
179
+ import time
180
+
181
+ from config.sweep_config import DEFAULT_AGE_SECONDS
182
+ from config.sweep_config import DEFAULT_POLL_INTERVAL
183
+
184
+
185
+ def _log_walk_error(os_error: OSError) -> None:
186
+ print(f"warning: cannot scan {os_error.filename} — {os_error.strerror}", file=sys.stderr)
187
+
188
+
189
+ def sweep(root: str, min_age_seconds: int) -> list[str]:
190
+ """Remove empty directories under *root* older than *min_age_seconds*."""
191
+
192
+ now = time.time()
193
+ removed: list[str] = []
194
+
195
+ for each_directory_path, _, _ in os.walk(
196
+ root, onerror=_log_walk_error, topdown=False
197
+ ):
198
+ try:
199
+ created = os.path.getctime(each_directory_path)
200
+ except OSError:
201
+ continue
202
+ if now - created >= min_age_seconds:
203
+ try:
204
+ os.rmdir(each_directory_path)
205
+ print(f"deleted: {each_directory_path}")
206
+ removed.append(each_directory_path)
207
+ except OSError:
208
+ pass
209
+
210
+ return removed
211
+
212
+
213
+ def _build_parser() -> argparse.ArgumentParser:
214
+ parser = argparse.ArgumentParser(description="Delete empty directories older than a given age.")
215
+ parser.add_argument("root", help="Root directory to scan")
216
+ parser.add_argument("--age", type=int, default=DEFAULT_AGE_SECONDS,
217
+ help=f"Minimum age in seconds (default: {DEFAULT_AGE_SECONDS} = 2 minutes)")
218
+ parser.add_argument("--once", action="store_true",
219
+ help="Single pass and exit instead of watching in a loop")
220
+ parser.add_argument("--interval", type=int, default=DEFAULT_POLL_INTERVAL,
221
+ help=f"Poll interval in seconds when looping (default: {DEFAULT_POLL_INTERVAL})")
222
+ return parser
223
+
224
+
225
+ def main() -> None:
226
+ parser = _build_parser()
227
+ arguments = parser.parse_args()
228
+
229
+ if not os.path.isdir(arguments.root):
230
+ print(f"error: not a directory: {arguments.root}", file=sys.stderr)
231
+ sys.exit(1)
232
+
233
+ if arguments.once:
234
+ sweep(arguments.root, arguments.age)
235
+ return
236
+
237
+ print(f"watching {arguments.root} every {arguments.interval}s (age threshold: {arguments.age}s)")
238
+ try:
239
+ while True:
240
+ sweep(arguments.root, arguments.age)
241
+ time.sleep(arguments.interval)
242
+ except KeyboardInterrupt:
243
+ print("\nstopped.")
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
248
+ ```
249
+
250
+ ### packages/claude-dev-env/scripts/config/sweep_config.py
251
+ ```python
252
+ """Centralized timing configuration for sweep_empty_dirs."""
253
+
254
+ DEFAULT_AGE_SECONDS: int = 120
255
+ DEFAULT_POLL_INTERVAL: int = 30
256
+ ```
257
+
258
+ ### packages/claude-dev-env/scripts/tests/test_sweep_empty_dirs.py
259
+ ```python
260
+ """Tests for sweep_empty_dirs.py"""
261
+
262
+ from __future__ import annotations
263
+
264
+ import datetime
265
+ import os
266
+ import subprocess
267
+ import sys
268
+ import tempfile
269
+ import time
270
+ from pathlib import Path
271
+
272
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
273
+ if str(_SCRIPTS_DIR) not in sys.path:
274
+ sys.path.insert(0, str(_SCRIPTS_DIR))
275
+
276
+ from sweep_empty_dirs import sweep # noqa: E402
277
+
278
+
279
+ def _set_creation_time_windows(path: str, timestamp: float) -> None:
280
+ dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
281
+ date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
282
+ subprocess.run(
283
+ ["powershell", "-Command",
284
+ f"(Get-Item '{path}').CreationTimeUtc = [DateTime]'{date_str}'"],
285
+ check=True, capture_output=True,
286
+ )
287
+
288
+
289
+ def test_deletes_empty_dir_older_than_threshold() -> None:
290
+ with tempfile.TemporaryDirectory() as tmp:
291
+ empty_dir = os.path.join(tmp, "old_empty")
292
+ os.mkdir(empty_dir)
293
+ _set_creation_time_windows(empty_dir, time.time() - 300)
294
+ removed = sweep(tmp, min_age_seconds=120)
295
+ assert empty_dir in removed
296
+ assert not os.path.isdir(empty_dir)
297
+
298
+
299
+ def test_skips_empty_dir_newer_than_threshold() -> None:
300
+ with tempfile.TemporaryDirectory() as tmp:
301
+ fresh_dir = os.path.join(tmp, "fresh_empty")
302
+ os.mkdir(fresh_dir)
303
+ removed = sweep(tmp, min_age_seconds=120)
304
+ assert fresh_dir not in removed
305
+ assert os.path.isdir(fresh_dir)
306
+
307
+
308
+ def test_deletes_nested_empty_dirs() -> None:
309
+ with tempfile.TemporaryDirectory() as tmp:
310
+ leaf = os.path.join(tmp, "parent", "child", "leaf")
311
+ os.makedirs(leaf)
312
+ _set_creation_time_windows(os.path.join(tmp, "parent"), time.time() - 300)
313
+ _set_creation_time_windows(os.path.join(tmp, "parent", "child"), time.time() - 300)
314
+ _set_creation_time_windows(leaf, time.time() - 300)
315
+ removed = sweep(tmp, min_age_seconds=120)
316
+ assert leaf in removed
317
+ assert os.path.join(tmp, "parent", "child") in removed
318
+ assert os.path.join(tmp, "parent") in removed
319
+
320
+
321
+ def test_empty_root_does_not_crash() -> None:
322
+ with tempfile.TemporaryDirectory() as tmp:
323
+ _set_creation_time_windows(tmp, time.time() - 300)
324
+ sweep(tmp, min_age_seconds=120)
325
+
326
+
327
+ def test_skips_nonempty_dir() -> None:
328
+ with tempfile.TemporaryDirectory() as tmp:
329
+ nonempty_dir = os.path.join(tmp, "has_stuff")
330
+ os.mkdir(nonempty_dir)
331
+ Path(nonempty_dir, "keepme.txt").write_text("hello")
332
+ removed = sweep(tmp, min_age_seconds=0)
333
+ assert nonempty_dir not in removed
334
+ assert os.path.isdir(nonempty_dir)
335
+ ```
336
+
337
+ ### packages/claude-dev-env/scripts/Install-SweepEmptyDirs.ps1
338
+ ```powershell
339
+ #!/usr/bin/env pwsh
340
+ param(
341
+ [Parameter(ParameterSetName = "install")]
342
+ [string]$Target,
343
+
344
+ [Parameter(ParameterSetName = "install")]
345
+ [int]$IntervalMinutes = 5,
346
+
347
+ [Parameter(ParameterSetName = "install")]
348
+ [int]$AgeSeconds = 120,
349
+
350
+ [Parameter(ParameterSetName = "remove")]
351
+ [switch]$Remove,
352
+
353
+ [Parameter(ParameterSetName = "status")]
354
+ [switch]$Status
355
+ )
356
+
357
+ $TaskName = "SweepEmptyDirs"
358
+
359
+ if ($Status) {
360
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
361
+ if (-not $task) {
362
+ Write-Host "STATUS: $TaskName is not registered."
363
+ return
364
+ }
365
+ Write-Host "STATUS: $TaskName is registered."
366
+ Write-Host " State: $($task.State)"
367
+ Write-Host " Actions:"
368
+ foreach ($action in $task.Actions) {
369
+ Write-Host " $($action.Execute) $($action.Arguments)"
370
+ }
371
+ Write-Host " Triggers:"
372
+ foreach ($trigger in $task.Triggers) {
373
+ Write-Host " $($trigger.Repetition.Interval) (starting $($trigger.StartBoundary))"
374
+ }
375
+ return
376
+ }
377
+
378
+ if ($Remove) {
379
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
380
+ Write-Host "$TaskName removed."
381
+ return
382
+ }
383
+
384
+ $ScriptDir = Split-Path -Parent $PSCommandPath
385
+ $ScriptPath = Join-Path $ScriptDir "sweep_empty_dirs.py"
386
+
387
+ if (-not (Test-Path $ScriptPath)) {
388
+ Write-Error "sweep_empty_dirs.py not found at: $ScriptPath"
389
+ exit 1
390
+ }
391
+
392
+ if (-not $Target) {
393
+ Write-Error "Parameter -Target is required (the directory to watch)."
394
+ exit 1
395
+ }
396
+
397
+ if (-not (Test-Path $Target)) {
398
+ Write-Error "Target directory does not exist: $Target"
399
+ exit 1
400
+ }
401
+
402
+ $_py = Get-Command py -ErrorAction SilentlyContinue
403
+ $PythonPath = if ($_py) { $_py.Source } else { (Get-Command python).Source }
404
+ if (-not $PythonPath) {
405
+ Write-Error "Cannot find Python (py or python) on PATH."
406
+ exit 1
407
+ }
408
+ $Action = New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath --once --age $AgeSeconds ""$Target"""
409
+ $Trigger = New-ScheduledTaskTrigger -Daily -At "00:00" -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
410
+ $Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
411
+
412
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Force | Out-Null
413
+ Write-Host "$TaskName registered — runs every ${IntervalMinutes}min against '$Target' (age ≥ ${AgeSeconds}s)."
414
+ ```