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,429 @@
1
+ Audit [REPO/ARTIFACT] [TARGET_ID] for **Category I only** (concurrency hazards). Skip A–H, J–K. Sub-bucket forced-exhaustion mode: Category I is decomposed into [N] 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 — including: is this code single-threaded, threaded, asyncio, multiprocessing, or mixed? Name the runtime (CPython 3.x, Node, Go, JVM, .NET, PowerShell runspace, browser JS), the concurrency primitives actually present (`threading`, `asyncio`, `multiprocessing`, `concurrent.futures`, `Thread`, `goroutine`, `Promise`, `Task`, `Start-ThreadJob`, `ForEach-Object -Parallel`, etc.), and the inter-process surface (shared filesystem, shared DB, shared cache, shared queue, signals). State explicitly which primitives are absent so each sub-bucket has a Shape B basis.]
4
+
5
+ ID prefix: `find`.
6
+
7
+ ## Source material
8
+
9
+ Inline the artifact under audit below this section. Chunking guidance — pick the chunk size that lets findings cite `[section name]:[line/paragraph N]` unambiguously: see [`../source-material-section-types.md`](../source-material-section-types.md). For a code PR, one section = one file in the diff. For a long module audited standalone, one section = one function or class. For a design doc, one section = one named heading.
10
+
11
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
12
+
13
+ **I1. Shared mutable state without synchronization**
14
+ - Identify every module-level / class-level / closure-captured mutable container (lists, dicts, sets, deques, mutable dataclass fields, ORM session singletons, in-process caches). For each, state who writes it and who reads it, and whether any writer/reader pair runs in different threads, coroutines, or processes.
15
+ - Identify function-local accumulators that escape the function (returned, yielded, stored on `self`, passed to a callback) and check whether the caller exposes them to concurrent access.
16
+ - Mutable default arguments (`def f(x=[]):`) — these are shared across calls and are an I1 hazard the moment two callers coexist.
17
+ - Class-level attributes initialized once at class-body evaluation that are then mutated on instances (mutable class-default trap).
18
+ - Adversarial probes (apply if Shape B): (a) does any imported library install a module-level singleton (e.g., a global cache, a logger handler list) that this code mutates? (b) is there a `nonlocal` or closure capture that pins a mutable across an `async`/`spawn`/`submit` boundary? (c) does serialization (pickle, JSON round-trip) hide a shared reference that survives the boundary?
19
+
20
+ **I2. Missing await on async operations**
21
+ - Every call to an `async def` function must be `await`ed, scheduled via `asyncio.create_task`, or wrapped in `asyncio.gather`/`asyncio.ensure_future`. Discarded coroutines are silent no-ops that emit `RuntimeWarning: coroutine ... was never awaited` only at GC time.
22
+ - Scan for async-returning APIs whose name does not contain `async` (e.g., `httpx.AsyncClient.get`, `aiofiles.open`, anything decorated with `@asynccontextmanager`).
23
+ - Sync-looking calls inside `async def` that secretly return a coroutine (auto-instrumentation, monkeypatched libraries, Django Channels' `database_sync_to_async`).
24
+ - `asyncio.create_task` without holding a reference — Python 3.11+ may GC the task before completion.
25
+ - Adversarial probes: (a) does any `async def` function have a return path that constructs a coroutine via partial application or factory and discards it? (b) does any test invoke an async function as if it were sync and only checks return type? (c) is `asyncio.run` nested inside another running loop, silently dropping the inner coroutine?
26
+
27
+ **I3. Lock ordering / deadlock potential**
28
+ - Enumerate every lock primitive (`threading.Lock`, `RLock`, `Semaphore`, `Condition`, `Event`, `asyncio.Lock`, `multiprocessing.Lock`, file locks via `fcntl`/`msvcrt`, distributed locks via Redis/DB).
29
+ - For each pair of locks, determine the acquisition order on every code path. Two paths acquiring `(A, B)` and `(B, A)` is a textbook deadlock.
30
+ - Re-entrant calls into code that re-acquires a non-reentrant lock from the same thread.
31
+ - `with lock: await something()` — holding a `threading.Lock` across an `await` boundary blocks the entire event loop.
32
+ - External-service waits inside a critical section (HTTP call, DB query, `time.sleep` while holding a lock).
33
+ - Adversarial probes: (a) does a registered signal handler or `atexit` callback acquire a lock that the main flow may already hold? (b) does any callback passed to a third-party library (`os.walk(onerror=...)`, `logging.Handler.emit`) acquire a lock the third party also holds? (c) is there a lock acquired in a generator's `__enter__` and released in `__exit__` that a `GeneratorExit` could skip?
34
+
35
+ **I4. Race conditions / TOCTOU**
36
+ - Every check-then-act pair on shared state is a potential race. Enumerate filesystem TOCTOU: `os.path.exists` then `open`, `Test-Path` then `Register-*`, `os.path.getctime` then `os.rmdir`, `stat` then `chmod`, `readdir` then `unlink`.
37
+ - Database TOCTOU: `SELECT` then `INSERT` without a unique constraint or `INSERT ... ON CONFLICT`.
38
+ - Cache TOCTOU: `cache.get` returning miss, then `compute()`, then `cache.set` — two callers compute twice (cache stampede).
39
+ - HTTP TOCTOU: `HEAD` then `GET`, or `GET` then `PUT` without `If-Match`/ETag.
40
+ - For each TOCTOU window, identify the protective mechanism (atomic primitive, exception handler, idempotent operation) and verify it correctly absorbs every failure mode of the race, not just the happy-path one.
41
+ - Adversarial probes: (a) what is the lower bound on the TOCTOU window on a slow filesystem (NFS, SMB, FUSE)? (b) does the protective `except` clause catch the full error surface (not just `FileNotFoundError` when `OSError` would also include `PermissionError`, `OSError(errno.EREMOTE)`, `OSError(errno.EBUSY)`)? (c) does symlink replacement between check and act bypass the check?
42
+
43
+ **I5. Atomicity of compound operations**
44
+ - Read-modify-write sequences on shared state without an atomic primitive: `counter = counter + 1`, `dict[k] = dict.get(k, 0) + 1`, `list[i] += 1`, `set.add(x)` after `if x not in set`.
45
+ - Multi-statement updates that must succeed or fail as a unit but are not wrapped in a transaction / lock / `with` block.
46
+ - Compound filesystem ops where no single syscall expresses the intent: "delete if older than X" (must `stat` then `unlink`), "rename if doesn't exist" (must `stat` then `rename` — POSIX `rename` is atomic but overwrites, Windows `rename` fails on overwrite).
47
+ - DB compound ops outside a transaction: read-then-update without `SELECT FOR UPDATE`, `UPDATE ... WHERE version = ?` (optimistic locking missing).
48
+ - CPython GIL gives some atomicity to single bytecode ops (list.append, dict.__setitem__) but not to `+=` or `dict.setdefault` callbacks.
49
+ - Adversarial probes: (a) does any single statement compile to multiple bytecodes (e.g., `obj.attr += 1`) that the GIL does not protect? (b) is there a JSON config rewritten by `json.dump(open(path, 'w'))` — non-atomic; concurrent reader sees a truncated file mid-write? (c) does a "transactional" wrapper actually start a transaction, or just open a connection?
50
+
51
+ **I6. Thread-local / async-local context bleed**
52
+ - `threading.local()` instances surviving thread-pool reuse (the same OS thread services many tasks; the `local` is keyed to the OS thread, not the logical task).
53
+ - `contextvars.ContextVar` set without `Context.run(...)` — propagation across `asyncio.create_task` is automatic but copying-on-create; mutations after task creation do not propagate.
54
+ - Request-scoped state stored on a module global (Flask `g`, Django thread-local request) leaking when the framework's scoping does not match the actual concurrency model.
55
+ - ORM session-per-request that is reused across requests due to a misconfigured scope.
56
+ - `asyncio` task-local state inside an executor (`run_in_executor` runs in a thread, not a coroutine — `contextvars` may or may not propagate depending on Python version and library version).
57
+ - Adversarial probes: (a) does any helper called from both sync and async paths assume the same context-storage primitive? (b) is there a pool-warmup that pre-populates `threading.local` and assumes it stays populated forever? (c) does logging context (correlation ID) propagate across `loop.run_in_executor`?
58
+
59
+ **I7. Cancellation handling**
60
+ - Every `await` inside an `async def` is a cancellation point. Cleanup code that follows `await` may be skipped on `CancelledError`.
61
+ - `asyncio.shield(...)` to protect critical cleanup; verify that what is shielded is genuinely critical and not just convenient.
62
+ - `try/except Exception:` swallowing `CancelledError` — `CancelledError` inherits from `BaseException` in 3.8+ but the codebase may run on older Python.
63
+ - Synchronous cancellation: `KeyboardInterrupt` landing between two syscalls; `SIGTERM` arriving mid-cleanup.
64
+ - `asyncio.timeout()` (3.11+) vs `asyncio.wait_for(...)` semantics — both raise `CancelledError`, both can race with the wrapped task completing.
65
+ - Adversarial probes: (a) does any `finally` block contain an `await` that could itself be cancelled, leaving cleanup half-done? (b) does the code rely on `__aexit__` running to release a resource, and does the cancellation path actually invoke `__aexit__`? (c) is there a `task.cancel()` call without `await task` afterward to surface the cancellation result?
66
+
67
+ **I8. Signal handling in multi-threaded code**
68
+ - Python: signals are always delivered to the main thread. A custom signal handler installed by `signal.signal(...)` on a non-main thread silently no-ops.
69
+ - A long-running computation in a non-main thread cannot be interrupted by `KeyboardInterrupt` directly; the main thread sees the signal but the worker keeps running until it cooperates (yields, returns, blocks).
70
+ - `asyncio` and signals: `loop.add_signal_handler` is the correct primitive; `signal.signal` from inside a running loop subverts the loop.
71
+ - Re-entrancy: signal handlers can interrupt anything, including a critical section guarded by a lock — handlers must use only async-signal-safe operations.
72
+ - C extensions / native code: a long-running C call (NumPy op, gzip decompress) blocks signal delivery until it returns.
73
+ - Adversarial probes: (a) does any module install a signal handler at import time that conflicts with the framework's own handler (Django, Celery, gunicorn)? (b) does the code assume `Ctrl-C` reaches a non-main thread when the runtime guarantees otherwise? (c) is there a `signal.signal(SIGTERM, ...)` registered in a place that would silently no-op if the module is ever imported from a non-main thread (worker process, plugin loader)?
74
+
75
+ ## Cross-bucket questions to answer at the end
76
+
77
+ Q1: Is there a critical section that spans two or more sub-buckets such that a future refactor adding concurrency (I1) would silently corrupt state already racing in I4/I5? Cite the line range.
78
+
79
+ Q2: What is the worst race-condition hazard introduced by this artifact in its current form (single-threaded or otherwise)? Cite `<file>:<line>` for the windowed pair, and explain the worst-case observable outcome.
80
+
81
+ Q3: If a future change introduced (or expanded) concurrency — wrapping the hot path in a thread pool, making it async, sharding across processes, or moving state to a shared store — which line would be the first to break atomicity, and which sub-bucket (I1/I3/I5/I6) would catch it?
82
+
83
+ ## Output
84
+
85
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket I1–I8, produce Shape A or Shape B (with ≥3 probes). Cross-bucket Q1–Q3 answers after the per-sub-bucket walk. **Adversarial second pass (P1 quota):** "assume your first pass missed at least 3 P1 race conditions across these 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
86
+
87
+ ---
88
+
89
+ # Worked example: jl-cmd/claude-code-config PR #394
90
+
91
+ Audit jl-cmd/claude-code-config PR #394 for **Category I only** (concurrency hazards). Skip A–H, J–K. Sub-bucket forced-exhaustion mode: Category I 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.
92
+
93
+ PR: feat(scripts): add sweep-empty-dirs utility and scheduled-task installer
94
+ Head SHA: 62c9c169ee7a44824e5da25c4cf8b74fdca08a53
95
+ ID prefix: `find`.
96
+
97
+ ## ARTIFACT METADATA — concurrency model
98
+
99
+ This PR ships a single-threaded synchronous Python script and a single-threaded PowerShell installer. There is **no `asyncio`, no `threading`, no `multiprocessing`, no `concurrent.futures`** anywhere in the diff. The only concurrency surface is **inter-process** — another process on the same filesystem can mutate (delete, repopulate, re-attribute) a directory between the moment the script enumerates it and the moment the script acts on it. The interesting Category I surfaces are therefore:
100
+
101
+ - **TOCTOU windows** between `os.walk` enumeration and the per-entry `os.path.getctime` / `os.rmdir` calls inside `sweep()`.
102
+ - **TOCTOU windows** between PowerShell `Test-Path $Target` and the subsequent `Register-ScheduledTask` call.
103
+ - Whatever in-process state the Python `sweep()` function carries across iterations of its `for ... in os.walk(...)` loop (the local `removed: list[str]` accumulator).
104
+
105
+ Treat the protective `try/except OSError` blocks at sweep_empty_dirs.py:26-29 (the `getctime` block) and sweep_empty_dirs.py:31-36 (the `rmdir` block) as **race-handling protective code, not silent-failure observability defects**. Their Category F framing (silent-failure observability) is out of scope here; their Category I role is to absorb a TOCTOU race correctly. Evaluate whether they do absorb the race correctly — that is the I4 question, not "should they log louder."
106
+
107
+ ## Sub-buckets (each requires Shape A finding OR Shape B with ≥3 adversarial probes)
108
+
109
+ **I1. Shared mutable state without synchronization**
110
+ - `removed: list[str]` is declared at sweep_empty_dirs.py:21 inside `sweep()`'s body. Verify it is a function-local (re-allocated on every call), not a module-level or class-level binding that could be mutated from a second caller.
111
+ - Module-level state in `sweep_empty_dirs.py`: `DEFAULT_AGE_SECONDS` and `DEFAULT_POLL_INTERVAL` are imported at sweep_empty_dirs.py:9-10 from `config/sweep_config.py`. Verify these are read-only `int` rebinds — never mutated.
112
+ - The `main()` watch-loop at sweep_empty_dirs.py:127-129 calls `sweep(...)` repeatedly in a single thread; verify no closure or default-argument captures a mutable container that persists across iterations.
113
+ - Class-level mutable defaults: there are no classes in the diff. Verify by scanning for `class ` in the inlined source.
114
+
115
+ **I2. Missing await on async operations**
116
+ - There are zero `async def` definitions in the diff. Verify by scanning the inlined source of all four files for `async`, `await`, `asyncio`, `coroutine`.
117
+ - `subprocess.run(...)` at the test file (`test_sweep_empty_dirs.py`) is the synchronous form; verify the test never accidentally imports `asyncio.subprocess` or `subprocess.Popen` and forgets to wait.
118
+ - `time.sleep(arguments.interval)` at sweep_empty_dirs.py:129 is the synchronous blocking sleep; verify it is not paired with any cooperative scheduling primitive that would require an await.
119
+
120
+ **I3. Lock ordering / deadlock potential**
121
+ - There are zero `threading.Lock`, `threading.RLock`, `asyncio.Lock`, `multiprocessing.Lock`, or `with ... :` lock-context-manager usages in the diff. Verify by scanning the inlined source.
122
+ - The PowerShell installer takes no lock on the scheduled-task store; `Register-ScheduledTask -Force` at Install-SweepEmptyDirs.ps1:90 is the only writer. Verify there is no `Get-ScheduledTask` followed by a conditional `Register-ScheduledTask` whose interleaving with another administrator's edit could deadlock the Task Scheduler service.
123
+ - Filesystem-level locking (`msvcrt.locking`, `fcntl.flock`) is not used. Verify by scanning.
124
+
125
+ **I4. Race conditions / TOCTOU** ⭐ canonical I case for this PR
126
+ This is the highest-signal sub-bucket. There are TWO concrete TOCTOU windows in the diff. Cite both explicitly.
127
+
128
+ *Window 1 — Python sweep() enumeration vs per-entry actions.*
129
+ - `os.walk(root, onerror=_log_walk_error, topdown=False)` at sweep_empty_dirs.py:23-25 yields each directory path. Inside the loop body, sweep_empty_dirs.py:27 calls `os.path.getctime(each_directory_path)`, and sweep_empty_dirs.py:32 calls `os.rmdir(each_directory_path)`.
130
+ - Between `os.walk` enumerating a path and the in-loop `getctime`/`rmdir`, another process can:
131
+ - Delete the directory entirely → `getctime` raises `FileNotFoundError` (subclass of `OSError`), absorbed by the except at sweep_empty_dirs.py:28-29 (`continue`). Race handled.
132
+ - Repopulate the directory with files → `os.rmdir` raises `OSError` (`ENOTEMPTY`), absorbed by sweep_empty_dirs.py:35-36 (`pass`). Race handled — but `removed.append(...)` at sweep_empty_dirs.py:34 was NOT yet appended (it's inside the same try). Verify the append is correctly gated by successful `rmdir`.
133
+ - Replace the directory with a symlink → `os.rmdir` on a symlink targeting a populated dir behaves OS-dependently; on Windows `os.rmdir` removes a directory symlink without touching the target, on POSIX it raises `ENOTDIR`. Verify the protective except is broad enough.
134
+ - Update creation time on the directory between `getctime` (sweep_empty_dirs.py:27) and the `if` check (sweep_empty_dirs.py:30) → no real window here because the local `created` is already captured; the `now - created` comparison uses the captured value. Verify by re-reading the loop body.
135
+ - `topdown=False` means children are visited before parents. Verify that another process creating a file inside a child directory between the child's `rmdir` and the parent's `rmdir` does not cause the parent's `rmdir` to silently spare a now-non-empty parent — and that this is the *intended* behavior (skip parents whose children re-populated).
136
+
137
+ *Window 2 — PowerShell Test-Path vs Register-ScheduledTask.*
138
+ - `Test-Path $Target` at Install-SweepEmptyDirs.ps1:77 returns truthy, the script proceeds, and `Register-ScheduledTask -TaskName $TaskName ... -Force` runs at Install-SweepEmptyDirs.ps1:90.
139
+ - Between line 77 and line 90, another process can delete `$Target`. The schedule will still register because `Register-ScheduledTask` does not re-check `$Target`'s existence — the action argument is just a string. Verify whether the Action's path validation is deferred to first execution (low-impact: scheduled task fails on first run with a clear error rather than at install time).
140
+ - A second TOCTOU exists between the `$ScriptPath` check at Install-SweepEmptyDirs.ps1:81-84 (`if (-not (Test-Path $ScriptPath)) { ... exit 1 }`) and the `New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath ..."` at Install-SweepEmptyDirs.ps1:89. If `sweep_empty_dirs.py` is removed between those lines, the registration succeeds with a stale path. Same low-impact pattern.
141
+ - A third TOCTOU exists between `Get-Command python` (or `py`) at Install-SweepEmptyDirs.ps1:80 and the `New-ScheduledTaskAction -Execute $PythonPath` at Install-SweepEmptyDirs.ps1:89. If Python is uninstalled between those lines, the registered task captures a stale absolute path.
142
+
143
+ *Adversarial probes for I4 (apply if no Shape A finding):*
144
+ - (a) On Windows, can `os.path.getctime` for a directory be coerced to lie by `SetFileTime` from another process between the directory's creation and the sweep? Cite docs.
145
+ - (b) Does `os.walk(topdown=False)` re-stat the parent after walking children, or does it cache the parent's path-string from the original enumeration?
146
+ - (c) On a network filesystem (SMB / NFS), what is the lower bound on the TOCTOU window between `os.walk` yielding a path and `os.rmdir` acting on it, and does the protective `OSError` catch handle the wider error surface (e.g., `OSError` with `errno.EREMOTE`)?
147
+
148
+ **I5. Atomicity of compound operations**
149
+ - The `removed.append(each_directory_path)` at sweep_empty_dirs.py:34 is a single-statement append on a function-local list; in CPython, list.append is atomic under the GIL. Verify there is no concurrent reader of `removed` (there isn't — `sweep` is called synchronously and returns `removed`).
150
+ - Read-modify-write on filesystem state: the `os.path.getctime → if → os.rmdir` triple at sweep_empty_dirs.py:27, 30, 32 is a non-atomic compound operation. The TOCTOU window between the `getctime` snapshot and the `rmdir` is the I4 case above; the I5 question is whether the operation should have used a single atomic primitive (it cannot — POSIX/Windows do not expose "rmdir if older than X" as one syscall), so a check-then-act is the correct pattern. Verify the protective except handles the non-atomic failure modes.
151
+ - The PowerShell installer's `Unregister-ScheduledTask`/`Register-ScheduledTask` pair is NOT used together (only `Register-ScheduledTask -Force` runs on install at Install-SweepEmptyDirs.ps1:90); `-Force` makes the register operation atomic from the caller's perspective (replace-or-create). Verify by reading the Microsoft docs for `Register-ScheduledTask -Force`.
152
+
153
+ **I6. Thread-local / async-local context bleed**
154
+ - There are zero `threading.local()`, `contextvars.ContextVar`, or async-context-manager usages in the diff. Verify by scanning the inlined source.
155
+ - The `main()` watch-loop at sweep_empty_dirs.py:126-131 runs in the foreground thread of a single Python process; there is no thread pool, no `concurrent.futures.ThreadPoolExecutor`, no `asyncio.run`. Verify.
156
+ - The PowerShell installer runs on a single PowerShell runspace; there is no `Start-ThreadJob`, no `ForEach-Object -Parallel`. Verify.
157
+
158
+ **I7. Cancellation handling**
159
+ - Python `KeyboardInterrupt` is caught at sweep_empty_dirs.py:130 (`except KeyboardInterrupt:`). Verify that:
160
+ - The interrupt landing inside `sweep(...)` (e.g., between `os.path.getctime` and `os.rmdir`) does NOT corrupt filesystem state. `os.rmdir` is atomic at the syscall level; `KeyboardInterrupt` between syscalls is safe.
161
+ - The interrupt landing inside `time.sleep(arguments.interval)` at sweep_empty_dirs.py:129 cleanly unwinds. `time.sleep` is interruptible by `KeyboardInterrupt` on all platforms in current CPython.
162
+ - The `print("\nstopped.")` at sweep_empty_dirs.py:131 runs on every interrupt path; verify the `try/except KeyboardInterrupt` wraps the entire `while True` loop.
163
+ - There is no `asyncio.CancelledError` to propagate. Verify by scanning.
164
+ - The PowerShell installer has no cancellation surface beyond Ctrl-C at the user's prompt; `Register-ScheduledTask` is not interruptible mid-call from PowerShell.
165
+
166
+ **I8. Signal handling in multi-threaded code**
167
+ - Python's default behavior is that `SIGINT` is delivered to the main thread; the diff installs no custom signal handlers (`signal.signal` is not imported or called). Verify by scanning the inlined source for `signal`.
168
+ - Because the script is single-threaded, the "signals always go to main thread" caveat does not bite — there is only one thread to receive them.
169
+ - The PowerShell installer does not register signal handlers; PowerShell's host handles Ctrl-C natively.
170
+ - Verify there is no future-hostile pattern (e.g., a `signal.signal(signal.SIGINT, handler)` in a place that would silently no-op if `sweep_empty_dirs.py` were ever imported as a module from a non-main thread).
171
+
172
+ ## Cross-bucket questions to answer at the end
173
+
174
+ Q1: Is there a critical section in `sweep()` that spans I4 (TOCTOU) and I5 (atomicity) such that a future refactor adding concurrency (I1) would silently corrupt the `removed` accumulator? Cite the line range.
175
+
176
+ Q2: What is the worst race-condition hazard introduced by this artifact in its current single-threaded form? Cite `sweep_empty_dirs.py:<line>` or `Install-SweepEmptyDirs.ps1:<line>` for the windowed pair, and explain the worst-case observable outcome (e.g., spurious deletion, stale registration, missed entry).
177
+
178
+ Q3: If a future change introduced concurrency (e.g., wrapped `sweep()` in `ThreadPoolExecutor` to walk multiple roots in parallel, or made `main()` async), which line would be the first to break atomicity, and which sub-bucket (I1/I3/I5/I6) would catch it?
179
+
180
+ ## Output
181
+
182
+ Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket I1-I8, 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 race conditions across these 8 sub-buckets — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
183
+
184
+ ## Diff (4 new files, all lines in scope)
185
+
186
+ ### packages/claude-dev-env/scripts/sweep_empty_dirs.py
187
+ ```python
188
+ #!/usr/bin/env python3
189
+ """Delete empty directories older than 2 minutes under a given root."""
190
+
191
+ import argparse
192
+ import os
193
+ import sys
194
+ import time
195
+
196
+ from config.sweep_config import DEFAULT_AGE_SECONDS
197
+ from config.sweep_config import DEFAULT_POLL_INTERVAL
198
+
199
+
200
+ def _log_walk_error(os_error: OSError) -> None:
201
+ print(f"warning: cannot scan {os_error.filename} — {os_error.strerror}", file=sys.stderr)
202
+
203
+
204
+ def sweep(root: str, min_age_seconds: int) -> list[str]:
205
+ """Remove empty directories under *root* older than *min_age_seconds*."""
206
+
207
+ now = time.time()
208
+ removed: list[str] = []
209
+
210
+ for each_directory_path, _, _ in os.walk(
211
+ root, onerror=_log_walk_error, topdown=False
212
+ ):
213
+ try:
214
+ created = os.path.getctime(each_directory_path)
215
+ except OSError:
216
+ continue
217
+ if now - created >= min_age_seconds:
218
+ try:
219
+ os.rmdir(each_directory_path)
220
+ print(f"deleted: {each_directory_path}")
221
+ removed.append(each_directory_path)
222
+ except OSError:
223
+ pass
224
+
225
+ return removed
226
+
227
+
228
+ def _build_parser() -> argparse.ArgumentParser:
229
+ parser = argparse.ArgumentParser(description="Delete empty directories older than a given age.")
230
+ parser.add_argument("root", help="Root directory to scan")
231
+ parser.add_argument("--age", type=int, default=DEFAULT_AGE_SECONDS,
232
+ help=f"Minimum age in seconds (default: {DEFAULT_AGE_SECONDS} = 2 minutes)")
233
+ parser.add_argument("--once", action="store_true",
234
+ help="Single pass and exit instead of watching in a loop")
235
+ parser.add_argument("--interval", type=int, default=DEFAULT_POLL_INTERVAL,
236
+ help=f"Poll interval in seconds when looping (default: {DEFAULT_POLL_INTERVAL})")
237
+ return parser
238
+
239
+
240
+ def main() -> None:
241
+ parser = _build_parser()
242
+ arguments = parser.parse_args()
243
+
244
+ if not os.path.isdir(arguments.root):
245
+ print(f"error: not a directory: {arguments.root}", file=sys.stderr)
246
+ sys.exit(1)
247
+
248
+ if arguments.once:
249
+ sweep(arguments.root, arguments.age)
250
+ return
251
+
252
+ print(f"watching {arguments.root} every {arguments.interval}s (age threshold: {arguments.age}s)")
253
+ try:
254
+ while True:
255
+ sweep(arguments.root, arguments.age)
256
+ time.sleep(arguments.interval)
257
+ except KeyboardInterrupt:
258
+ print("\nstopped.")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
263
+ ```
264
+
265
+ ### packages/claude-dev-env/scripts/config/sweep_config.py
266
+ ```python
267
+ """Centralized timing configuration for sweep_empty_dirs."""
268
+
269
+ DEFAULT_AGE_SECONDS: int = 120
270
+ DEFAULT_POLL_INTERVAL: int = 30
271
+ ```
272
+
273
+ ### packages/claude-dev-env/scripts/tests/test_sweep_empty_dirs.py
274
+ ```python
275
+ """Tests for sweep_empty_dirs.py"""
276
+
277
+ from __future__ import annotations
278
+
279
+ import datetime
280
+ import os
281
+ import subprocess
282
+ import sys
283
+ import tempfile
284
+ import time
285
+ from pathlib import Path
286
+
287
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
288
+ if str(_SCRIPTS_DIR) not in sys.path:
289
+ sys.path.insert(0, str(_SCRIPTS_DIR))
290
+
291
+ from sweep_empty_dirs import sweep # noqa: E402
292
+
293
+
294
+ def _set_creation_time_windows(path: str, timestamp: float) -> None:
295
+ dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
296
+ date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
297
+ subprocess.run(
298
+ ["powershell", "-Command",
299
+ f"(Get-Item '{path}').CreationTimeUtc = [DateTime]'{date_str}'"],
300
+ check=True, capture_output=True,
301
+ )
302
+
303
+
304
+ def test_deletes_empty_dir_older_than_threshold() -> None:
305
+ with tempfile.TemporaryDirectory() as tmp:
306
+ empty_dir = os.path.join(tmp, "old_empty")
307
+ os.mkdir(empty_dir)
308
+ _set_creation_time_windows(empty_dir, time.time() - 300)
309
+ removed = sweep(tmp, min_age_seconds=120)
310
+ assert empty_dir in removed
311
+ assert not os.path.isdir(empty_dir)
312
+
313
+
314
+ def test_skips_empty_dir_newer_than_threshold() -> None:
315
+ with tempfile.TemporaryDirectory() as tmp:
316
+ fresh_dir = os.path.join(tmp, "fresh_empty")
317
+ os.mkdir(fresh_dir)
318
+ removed = sweep(tmp, min_age_seconds=120)
319
+ assert fresh_dir not in removed
320
+ assert os.path.isdir(fresh_dir)
321
+
322
+
323
+ def test_deletes_nested_empty_dirs() -> None:
324
+ with tempfile.TemporaryDirectory() as tmp:
325
+ leaf = os.path.join(tmp, "parent", "child", "leaf")
326
+ os.makedirs(leaf)
327
+ _set_creation_time_windows(os.path.join(tmp, "parent"), time.time() - 300)
328
+ _set_creation_time_windows(os.path.join(tmp, "parent", "child"), time.time() - 300)
329
+ _set_creation_time_windows(leaf, time.time() - 300)
330
+ removed = sweep(tmp, min_age_seconds=120)
331
+ assert leaf in removed
332
+ assert os.path.join(tmp, "parent", "child") in removed
333
+ assert os.path.join(tmp, "parent") in removed
334
+
335
+
336
+ def test_empty_root_does_not_crash() -> None:
337
+ with tempfile.TemporaryDirectory() as tmp:
338
+ _set_creation_time_windows(tmp, time.time() - 300)
339
+ sweep(tmp, min_age_seconds=120)
340
+
341
+
342
+ def test_skips_nonempty_dir() -> None:
343
+ with tempfile.TemporaryDirectory() as tmp:
344
+ nonempty_dir = os.path.join(tmp, "has_stuff")
345
+ os.mkdir(nonempty_dir)
346
+ Path(nonempty_dir, "keepme.txt").write_text("hello")
347
+ removed = sweep(tmp, min_age_seconds=0)
348
+ assert nonempty_dir not in removed
349
+ assert os.path.isdir(nonempty_dir)
350
+ ```
351
+
352
+ ### packages/claude-dev-env/scripts/Install-SweepEmptyDirs.ps1
353
+ ```powershell
354
+ #!/usr/bin/env pwsh
355
+ param(
356
+ [Parameter(ParameterSetName = "install")]
357
+ [string]$Target,
358
+
359
+ [Parameter(ParameterSetName = "install")]
360
+ [int]$IntervalMinutes = 5,
361
+
362
+ [Parameter(ParameterSetName = "install")]
363
+ [int]$AgeSeconds = 120,
364
+
365
+ [Parameter(ParameterSetName = "remove")]
366
+ [switch]$Remove,
367
+
368
+ [Parameter(ParameterSetName = "status")]
369
+ [switch]$Status
370
+ )
371
+
372
+ $TaskName = "SweepEmptyDirs"
373
+
374
+ if ($Status) {
375
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
376
+ if (-not $task) {
377
+ Write-Host "STATUS: $TaskName is not registered."
378
+ return
379
+ }
380
+ Write-Host "STATUS: $TaskName is registered."
381
+ Write-Host " State: $($task.State)"
382
+ Write-Host " Actions:"
383
+ foreach ($action in $task.Actions) {
384
+ Write-Host " $($action.Execute) $($action.Arguments)"
385
+ }
386
+ Write-Host " Triggers:"
387
+ foreach ($trigger in $task.Triggers) {
388
+ Write-Host " $($trigger.Repetition.Interval) (starting $($trigger.StartBoundary))"
389
+ }
390
+ return
391
+ }
392
+
393
+ if ($Remove) {
394
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
395
+ Write-Host "$TaskName removed."
396
+ return
397
+ }
398
+
399
+ $ScriptDir = Split-Path -Parent $PSCommandPath
400
+ $ScriptPath = Join-Path $ScriptDir "sweep_empty_dirs.py"
401
+
402
+ if (-not (Test-Path $ScriptPath)) {
403
+ Write-Error "sweep_empty_dirs.py not found at: $ScriptPath"
404
+ exit 1
405
+ }
406
+
407
+ if (-not $Target) {
408
+ Write-Error "Parameter -Target is required (the directory to watch)."
409
+ exit 1
410
+ }
411
+
412
+ if (-not (Test-Path $Target)) {
413
+ Write-Error "Target directory does not exist: $Target"
414
+ exit 1
415
+ }
416
+
417
+ $_py = Get-Command py -ErrorAction SilentlyContinue
418
+ $PythonPath = if ($_py) { $_py.Source } else { (Get-Command python).Source }
419
+ if (-not $PythonPath) {
420
+ Write-Error "Cannot find Python (py or python) on PATH."
421
+ exit 1
422
+ }
423
+ $Action = New-ScheduledTaskAction -Execute $PythonPath -Argument "$ScriptPath --once --age $AgeSeconds ""$Target"""
424
+ $Trigger = New-ScheduledTaskTrigger -Daily -At "00:00" -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
425
+ $Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
426
+
427
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Force | Out-Null
428
+ Write-Host "$TaskName registered — runs every ${IntervalMinutes}min against '$Target' (age ≥ ${AgeSeconds}s)."
429
+ ```