@caddis/cli 0.4.0 → 0.4.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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +1 -1
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +34 -5
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +186 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +186 -0
- package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +186 -0
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -728,7 +728,7 @@ def remove_rows_with_targets(text: str, targets: list[str]) -> str:
|
|
|
728
728
|
def _atomic_write(path: Path, text: str) -> bool:
|
|
729
729
|
"""Write ``text`` via a same-dir, per-process temp file + atomic replace. Returns True on success.
|
|
730
730
|
|
|
731
|
-
A crash mid-write can't leave a truncated DOC-MAP
|
|
731
|
+
A crash mid-write can't leave a truncated DOC-MAP. The temp name
|
|
732
732
|
is PID-tagged so two concurrent runs don't fight over one temp. Returns **False (never raises)** if
|
|
733
733
|
the target is locked — on Windows an open editor / AV can hold it — so a maintenance run degrades to
|
|
734
734
|
a clear message instead of a stack trace.
|
|
@@ -896,6 +896,7 @@ agent-log.jsonl
|
|
|
896
896
|
.last-usage-review
|
|
897
897
|
relay.md
|
|
898
898
|
relay/
|
|
899
|
+
session-state.md
|
|
899
900
|
PROJECT-FACTS.md
|
|
900
901
|
memory.jsonl
|
|
901
902
|
"""
|
|
@@ -1131,12 +1132,13 @@ def scaffold_artifact_dir(target: Path, dry: bool) -> list[str]:
|
|
|
1131
1132
|
"""Create the harness-owned .caddis/ artifact tree + a default .gitignore + a config example.
|
|
1132
1133
|
|
|
1133
1134
|
Committed subdirs: plans, handoffs, agent-docs, prd, kb, prompts, comms. Transient state
|
|
1134
|
-
(reviews/*.html, usage-log.jsonl, .last-usage-review, relay*,
|
|
1135
|
-
is gitignored. .caddis/ is the default home for every working-artifact kind (Track A
|
|
1135
|
+
(reviews/*.html, usage-log.jsonl, .last-usage-review, relay*, session-state.md,
|
|
1136
|
+
PROJECT-FACTS.md, memory.jsonl) is gitignored. .caddis/ is the default home for every working-artifact kind (Track A
|
|
1136
1137
|
Phase A3) — kb/ and prompts/ round out plans/prd/agent-docs/reviews so nothing has to
|
|
1137
1138
|
scatter to the repo root or .github/. Also drops a documented `config.toml.example`
|
|
1138
|
-
(guard/doc_coverage). Idempotent
|
|
1139
|
-
|
|
1139
|
+
(guard/doc_coverage). Idempotent. An existing .caddis/.gitignore is never clobbered or
|
|
1140
|
+
reordered, but MISSING entries are appended — otherwise a repo set up before an entry
|
|
1141
|
+
existed would never receive it. The config example is left alone entirely.
|
|
1140
1142
|
|
|
1141
1143
|
Writes into the repo's `.caddis/`.
|
|
1142
1144
|
"""
|
|
@@ -1198,7 +1200,34 @@ def scaffold_artifact_dir(target: Path, dry: bool) -> list[str]:
|
|
|
1198
1200
|
notes.append(f"scaffold: wrote {label}/kb/environment-map.md")
|
|
1199
1201
|
gi = root / ".gitignore"
|
|
1200
1202
|
if gi.exists():
|
|
1201
|
-
|
|
1203
|
+
# APPEND what is missing rather than keeping the file frozen. The old behaviour was
|
|
1204
|
+
# "present -> kept", so a repo scaffolded before an entry was added NEVER received it.
|
|
1205
|
+
# That is not hypothetical: `session-state.md` is rewritten by the Stop hook on every
|
|
1206
|
+
# turn and contains the operator's verbatim words, and an already-scaffolded repo was
|
|
1207
|
+
# sitting one hook-fire away from committing it.
|
|
1208
|
+
#
|
|
1209
|
+
# Only ever adds; a line the user wrote themselves is never touched or reordered.
|
|
1210
|
+
try:
|
|
1211
|
+
existing = gi.read_text(encoding="utf-8")
|
|
1212
|
+
except OSError:
|
|
1213
|
+
existing = ""
|
|
1214
|
+
have = {ln.strip() for ln in existing.splitlines()}
|
|
1215
|
+
missing = [
|
|
1216
|
+
ln for ln in ARTIFACT_GITIGNORE.splitlines()
|
|
1217
|
+
if ln.strip() and not ln.startswith("#") and ln.strip() not in have
|
|
1218
|
+
]
|
|
1219
|
+
if not missing:
|
|
1220
|
+
notes.append(f"scaffold: {label}/.gitignore present — up to date")
|
|
1221
|
+
else:
|
|
1222
|
+
if not dry:
|
|
1223
|
+
block = "" if existing.endswith("\n") or not existing else "\n"
|
|
1224
|
+
block += "\n# added by caddis — newer transient artifacts\n"
|
|
1225
|
+
block += "\n".join(missing) + "\n"
|
|
1226
|
+
gi.write_text(existing + block, encoding="utf-8")
|
|
1227
|
+
notes.append(
|
|
1228
|
+
f"scaffold: {label}/.gitignore — added {len(missing)} missing entry(ies): "
|
|
1229
|
+
+ ", ".join(missing)
|
|
1230
|
+
)
|
|
1202
1231
|
else:
|
|
1203
1232
|
if not dry:
|
|
1204
1233
|
root.mkdir(parents=True, exist_ok=True)
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
| Ci Cd Pipeline | `ci-cd-pipeline/` | CI/CD pipeline design and implementation for GitHub Actions, Azure DevOps, and general pipeline architecture. Use when creating build pipelines, deployment workflows, quality gates, environment promotion strategies, or automating release processes. |
|
|
40
40
|
| Gh Cli | `gh-cli/` | GitHub CLI operations — issues, PRs, releases, and repo management |
|
|
41
41
|
| Git Commit | `git-commit/` | Create well-structured conventional commit messages following Conventional Commits standard. Use when committing changes, preparing PRs, or generating changelogs. |
|
|
42
|
+
| Safe Cleanup | `safe-cleanup/` | Use BEFORE deleting any directory tree, clearing a scratchpad, removing git worktrees, or "tidying up" generated folders - especially on Windows, where a recursive delete that meets a junction or symlink destroys the link's TARGET. Also use when verifying that a Python venv or node_modules survived a cleanup, because pip check and file counts both report healthy on a half-deleted environment. |
|
|
42
43
|
| Using Git Worktrees | `using-git-worktrees/` | Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification |
|
|
43
44
|
| Windows Deployment | `windows-deployment/` | Deploy FastAPI + React/Vite apps to Windows Server with NSSM services, reverse proxy (IIS or nginx), and git-pull workflow. Use when deploying any web app to a Windows prod server, setting up NSSM services, configuring IIS or nginx reverse proxy, making code environment-aware for dev/prod, or troubleshooting prod deployment issues. |
|
|
44
45
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: safe-cleanup
|
|
3
|
+
description: Use BEFORE deleting any directory tree, clearing a scratchpad, removing git worktrees, or "tidying up" generated folders - especially on Windows, where a recursive delete that meets a junction or symlink destroys the link's TARGET. Also use when verifying that a Python venv or node_modules survived a cleanup, because pip check and file counts both report healthy on a half-deleted environment.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Safe Cleanup
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Cleanup looks like the safest thing you do all day. It is the most destructive.
|
|
11
|
+
|
|
12
|
+
This skill exists because of a real incident: an agent created 31 directory
|
|
13
|
+
junctions from throwaway worktrees into live repositories, unlinked them, saw
|
|
14
|
+
"31 unlinked, 0 failed", then recursively deleted the parent. A junction it had
|
|
15
|
+
never seen survived the scan, the delete followed it, and **five repositories'
|
|
16
|
+
development environments were destroyed**. Every verification afterwards said
|
|
17
|
+
"intact", because the verification was wrong too.
|
|
18
|
+
|
|
19
|
+
Two hours of repair. None of it would have happened with the rules below.
|
|
20
|
+
|
|
21
|
+
## The one-paragraph version
|
|
22
|
+
|
|
23
|
+
On Windows, `Remove-Item -Recurse` **follows junctions and deletes their
|
|
24
|
+
targets**. Enumerating with `-ErrorAction SilentlyContinue` hides the ones you
|
|
25
|
+
cannot read. And after the damage, `pip check`, `pip list` and file counts all
|
|
26
|
+
report success, because metadata survives while package files do not. So: never
|
|
27
|
+
link into somewhere real, never recurse through a link, prefer rename over
|
|
28
|
+
delete, and verify by *using* the thing rather than counting it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## RULE 1 - Prefer rename over delete
|
|
33
|
+
|
|
34
|
+
Rename is atomic. Recursive delete is not: it can fail half way and leave a
|
|
35
|
+
directory that looks present and is unusable.
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
Rename-Item -Path $target -NewName ".broken-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In the incident, a venv holding `_greenlet.cp314-win_amd64.pyd` open **refused to
|
|
42
|
+
delete** but **renamed fine**. Had the delete been retried or forced, it would
|
|
43
|
+
have half-emptied the directory.
|
|
44
|
+
|
|
45
|
+
Rename first, build fresh alongside, delete the old copy later when nothing holds
|
|
46
|
+
it. Disk is cheap. An environment nobody can recover is not.
|
|
47
|
+
|
|
48
|
+
## RULE 2 - Never recurse a delete through a reparse point
|
|
49
|
+
|
|
50
|
+
Junctions and symlinks are transparent to `Remove-Item -Recurse`. Deleting a
|
|
51
|
+
folder that contains a link to `C:\live\thing` deletes **the contents of
|
|
52
|
+
`C:\live\thing`**.
|
|
53
|
+
|
|
54
|
+
Check first, and **fail if the check itself cannot complete**:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
$links = @(Get-ChildItem $path -Recurse -Force -Directory -ErrorAction Stop |
|
|
58
|
+
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
59
|
+
if ($links.Count) {
|
|
60
|
+
$links | ForEach-Object { Write-Host " $($_.FullName) -> $($_.Target)" }
|
|
61
|
+
throw "refusing: $($links.Count) link(s) inside $path"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`-ErrorAction SilentlyContinue` on that scan is precisely what caused the
|
|
66
|
+
incident. A link inside a directory the scanner could not read was skipped in
|
|
67
|
+
silence and never appeared in the count. **A scan that cannot see everything must
|
|
68
|
+
fail, not shrug.**
|
|
69
|
+
|
|
70
|
+
To remove a link itself, unlink it - never delete through it:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
[System.IO.Directory]::Delete($linkPath, $false) # link only, never the target
|
|
74
|
+
cmd /c rmdir "$linkPath" # same effect
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## RULE 3 - Do not create links into live directories
|
|
78
|
+
|
|
79
|
+
The safest delete is the one with nothing to follow.
|
|
80
|
+
|
|
81
|
+
If a throwaway workspace needs dependencies, **build them there**, or point the
|
|
82
|
+
tool at an absolute interpreter path. Do not junction `<scratch>\.venv` to
|
|
83
|
+
`<live-repo>\.venv` for convenience - that is a loaded gun aimed at the repo, and
|
|
84
|
+
any cleanup above the link can fire it.
|
|
85
|
+
|
|
86
|
+
If you must link, remove the link the moment you are done, and never leave one
|
|
87
|
+
inside a directory that something else will clean up.
|
|
88
|
+
|
|
89
|
+
## RULE 4 - Verify by using, not by counting
|
|
90
|
+
|
|
91
|
+
After any cleanup near an environment, prove it still works. These all LIE on a
|
|
92
|
+
half-deleted Python venv, because dist-info metadata survives while package files
|
|
93
|
+
are removed:
|
|
94
|
+
|
|
95
|
+
| Check | Why it lies |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `Test-Path .venv\Scripts\python.exe` | Proves the interpreter exists. Says nothing about packages. |
|
|
98
|
+
| file or directory counts | Metadata directories inflate the count. |
|
|
99
|
+
| `pip list` | Reads metadata. |
|
|
100
|
+
| `pip check` | Reads metadata. Reported "No broken requirements found" on a venv that could not import fastapi. |
|
|
101
|
+
| `pip install -e .` | Reads metadata, calls the package satisfied, **skips it, and reports success**. |
|
|
102
|
+
|
|
103
|
+
The honest check is whether the interpreter can import what the project declares:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import importlib.util
|
|
107
|
+
importlib.util.find_spec("fastapi") is None # -> True means genuinely missing
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For node, check that a real entry point resolves, not that `node_modules` exists.
|
|
111
|
+
|
|
112
|
+
**Map distribution names to import names.** `pyjwt` imports as `jwt`,
|
|
113
|
+
`python-ulid` as `ulid`, `python-dotenv` as `dotenv`. A naive audit produces false
|
|
114
|
+
positives and will have you rebuilding healthy environments - which is its own way
|
|
115
|
+
of causing an outage.
|
|
116
|
+
|
|
117
|
+
## RULE 5 - Stop what holds the files, or refuse
|
|
118
|
+
|
|
119
|
+
A running service turns a delete into a **partial** delete - the worst outcome.
|
|
120
|
+
|
|
121
|
+
Stop named services first. Then check for surviving processes and **refuse rather
|
|
122
|
+
than force**:
|
|
123
|
+
|
|
124
|
+
```powershell
|
|
125
|
+
$held = @(Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
|
126
|
+
Where-Object { $_.ExecutablePath -like "$venv\*" })
|
|
127
|
+
if ($held.Count) { throw "refusing: still held by PID $($held[0].ProcessId)" }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In the incident's repair, this refused on one repo because a **VS Code language
|
|
131
|
+
server** had held that venv since the previous day. Not a service, not safe to
|
|
132
|
+
kill, not the script's business. That repo was fixed a different way. **Refusing
|
|
133
|
+
was correct.**
|
|
134
|
+
|
|
135
|
+
## RULE 6 - One agent at a time on a shared resource
|
|
136
|
+
|
|
137
|
+
Two agents installing into the same environment concurrently can produce exactly
|
|
138
|
+
this partial state. Three sessions were live in one repository during the
|
|
139
|
+
incident. Whoever is repairing says so; everyone else keeps out.
|
|
140
|
+
|
|
141
|
+
## RULE 7 - Verify against the source of truth, not the working copy
|
|
142
|
+
|
|
143
|
+
Checkouts go stale. During the same incident a subagent reported a file as absent
|
|
144
|
+
from a configuration, contradicting a change made hours earlier. The file was
|
|
145
|
+
present on `origin/main`; the working copy was **10 commits behind**.
|
|
146
|
+
|
|
147
|
+
Before asserting something is missing, check `git show origin/main:<path>`, not
|
|
148
|
+
the file on disk.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Before you delete anything - the checklist
|
|
153
|
+
|
|
154
|
+
1. Can I **rename** instead? Do that.
|
|
155
|
+
2. Does the tree contain **reparse points**? Scan with `-ErrorAction Stop`. Refuse if any, or if the scan fails.
|
|
156
|
+
3. Does anything **hold files open**? Stop services; refuse on surviving processes.
|
|
157
|
+
4. Did I create any **links into live directories** earlier in this session? Unlink them explicitly, by name.
|
|
158
|
+
5. After: **verify by using**, not by counting.
|
|
159
|
+
6. If something looks wrong: check `origin/main`, not the working copy.
|
|
160
|
+
|
|
161
|
+
## What good verification output looks like
|
|
162
|
+
|
|
163
|
+
Not this:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
.venv intact: True <- only proves python.exe exists
|
|
167
|
+
31 unlinked, 0 failed <- says nothing about what the scan missed
|
|
168
|
+
No broken requirements found <- reads metadata, not reality
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
service-a OK declared=24 missing=0 <- every declared dep imported
|
|
175
|
+
service-b OK declared=19 missing=0
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Reference implementation
|
|
179
|
+
|
|
180
|
+
The estate this skill came from keeps working tools at `.caddis/tools/`:
|
|
181
|
+
|
|
182
|
+
- `venv-audit.py` - imports every dependency each `pyproject.toml` declares
|
|
183
|
+
- `rebuild-venv.ps1` - renames aside, rebuilds, restarts services, refuses on links or live processes
|
|
184
|
+
- `README-venv-safety.md` - the incident record
|
|
185
|
+
|
|
186
|
+
Adapt rather than copy: the rules matter more than the scripts.
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
| Changelog Generator | `changelog-generator/` | Automatically creates user-facing changelogs from git commits by analyzing commit history, categorizing changes, and transforming technical commits into clear, customer-friendly release notes. Turns hours of manual changelog writing into minutes of automated generation. |
|
|
52
52
|
| Deploy Local | `deploy-local/` | End-to-end local deployment loop for Gitea-hosted projects. Use when the user wants to commit on dev, push to remote, monitor the golden CI/build/deploy workflow, validate prod on the configured prod host, and fix lint/test/pipeline failures until deployment is healthy. |
|
|
53
53
|
| Monorepo | `monorepo/` | Monorepo management with Turborepo and pnpm workspaces. Use for Turborepo setup, turbo.json task dependencies, remote caching, pnpm workspace protocol, shared packages (ui-library, config, types), affected-only CI/CD builds, or monorepo structure (apps/ vs packages/). Covers pitfalls like circular deps, version drift, and hoisting issues. |
|
|
54
|
+
| Safe Cleanup | `safe-cleanup/` | Use BEFORE deleting any directory tree, clearing a scratchpad, removing git worktrees, or "tidying up" generated folders - especially on Windows, where a recursive delete that meets a junction or symlink destroys the link's TARGET. Also use when verifying that a Python venv or node_modules survived a cleanup, because pip check and file counts both report healthy on a half-deleted environment. |
|
|
54
55
|
| Update Readme | `update-readme/` | Detect feature commits and update README sections (Features, API, Usage) with accurate repo-aware changes, then stage only README for a docs commit. Use when users add features/routes/components and want README maintained automatically. |
|
|
55
56
|
|
|
56
57
|
### Docs
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: safe-cleanup
|
|
3
|
+
description: Use BEFORE deleting any directory tree, clearing a scratchpad, removing git worktrees, or "tidying up" generated folders - especially on Windows, where a recursive delete that meets a junction or symlink destroys the link's TARGET. Also use when verifying that a Python venv or node_modules survived a cleanup, because pip check and file counts both report healthy on a half-deleted environment.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Safe Cleanup
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Cleanup looks like the safest thing you do all day. It is the most destructive.
|
|
11
|
+
|
|
12
|
+
This skill exists because of a real incident: an agent created 31 directory
|
|
13
|
+
junctions from throwaway worktrees into live repositories, unlinked them, saw
|
|
14
|
+
"31 unlinked, 0 failed", then recursively deleted the parent. A junction it had
|
|
15
|
+
never seen survived the scan, the delete followed it, and **five repositories'
|
|
16
|
+
development environments were destroyed**. Every verification afterwards said
|
|
17
|
+
"intact", because the verification was wrong too.
|
|
18
|
+
|
|
19
|
+
Two hours of repair. None of it would have happened with the rules below.
|
|
20
|
+
|
|
21
|
+
## The one-paragraph version
|
|
22
|
+
|
|
23
|
+
On Windows, `Remove-Item -Recurse` **follows junctions and deletes their
|
|
24
|
+
targets**. Enumerating with `-ErrorAction SilentlyContinue` hides the ones you
|
|
25
|
+
cannot read. And after the damage, `pip check`, `pip list` and file counts all
|
|
26
|
+
report success, because metadata survives while package files do not. So: never
|
|
27
|
+
link into somewhere real, never recurse through a link, prefer rename over
|
|
28
|
+
delete, and verify by *using* the thing rather than counting it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## RULE 1 - Prefer rename over delete
|
|
33
|
+
|
|
34
|
+
Rename is atomic. Recursive delete is not: it can fail half way and leave a
|
|
35
|
+
directory that looks present and is unusable.
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
Rename-Item -Path $target -NewName ".broken-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In the incident, a venv holding `_greenlet.cp314-win_amd64.pyd` open **refused to
|
|
42
|
+
delete** but **renamed fine**. Had the delete been retried or forced, it would
|
|
43
|
+
have half-emptied the directory.
|
|
44
|
+
|
|
45
|
+
Rename first, build fresh alongside, delete the old copy later when nothing holds
|
|
46
|
+
it. Disk is cheap. An environment nobody can recover is not.
|
|
47
|
+
|
|
48
|
+
## RULE 2 - Never recurse a delete through a reparse point
|
|
49
|
+
|
|
50
|
+
Junctions and symlinks are transparent to `Remove-Item -Recurse`. Deleting a
|
|
51
|
+
folder that contains a link to `C:\live\thing` deletes **the contents of
|
|
52
|
+
`C:\live\thing`**.
|
|
53
|
+
|
|
54
|
+
Check first, and **fail if the check itself cannot complete**:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
$links = @(Get-ChildItem $path -Recurse -Force -Directory -ErrorAction Stop |
|
|
58
|
+
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
59
|
+
if ($links.Count) {
|
|
60
|
+
$links | ForEach-Object { Write-Host " $($_.FullName) -> $($_.Target)" }
|
|
61
|
+
throw "refusing: $($links.Count) link(s) inside $path"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`-ErrorAction SilentlyContinue` on that scan is precisely what caused the
|
|
66
|
+
incident. A link inside a directory the scanner could not read was skipped in
|
|
67
|
+
silence and never appeared in the count. **A scan that cannot see everything must
|
|
68
|
+
fail, not shrug.**
|
|
69
|
+
|
|
70
|
+
To remove a link itself, unlink it - never delete through it:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
[System.IO.Directory]::Delete($linkPath, $false) # link only, never the target
|
|
74
|
+
cmd /c rmdir "$linkPath" # same effect
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## RULE 3 - Do not create links into live directories
|
|
78
|
+
|
|
79
|
+
The safest delete is the one with nothing to follow.
|
|
80
|
+
|
|
81
|
+
If a throwaway workspace needs dependencies, **build them there**, or point the
|
|
82
|
+
tool at an absolute interpreter path. Do not junction `<scratch>\.venv` to
|
|
83
|
+
`<live-repo>\.venv` for convenience - that is a loaded gun aimed at the repo, and
|
|
84
|
+
any cleanup above the link can fire it.
|
|
85
|
+
|
|
86
|
+
If you must link, remove the link the moment you are done, and never leave one
|
|
87
|
+
inside a directory that something else will clean up.
|
|
88
|
+
|
|
89
|
+
## RULE 4 - Verify by using, not by counting
|
|
90
|
+
|
|
91
|
+
After any cleanup near an environment, prove it still works. These all LIE on a
|
|
92
|
+
half-deleted Python venv, because dist-info metadata survives while package files
|
|
93
|
+
are removed:
|
|
94
|
+
|
|
95
|
+
| Check | Why it lies |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `Test-Path .venv\Scripts\python.exe` | Proves the interpreter exists. Says nothing about packages. |
|
|
98
|
+
| file or directory counts | Metadata directories inflate the count. |
|
|
99
|
+
| `pip list` | Reads metadata. |
|
|
100
|
+
| `pip check` | Reads metadata. Reported "No broken requirements found" on a venv that could not import fastapi. |
|
|
101
|
+
| `pip install -e .` | Reads metadata, calls the package satisfied, **skips it, and reports success**. |
|
|
102
|
+
|
|
103
|
+
The honest check is whether the interpreter can import what the project declares:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import importlib.util
|
|
107
|
+
importlib.util.find_spec("fastapi") is None # -> True means genuinely missing
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For node, check that a real entry point resolves, not that `node_modules` exists.
|
|
111
|
+
|
|
112
|
+
**Map distribution names to import names.** `pyjwt` imports as `jwt`,
|
|
113
|
+
`python-ulid` as `ulid`, `python-dotenv` as `dotenv`. A naive audit produces false
|
|
114
|
+
positives and will have you rebuilding healthy environments - which is its own way
|
|
115
|
+
of causing an outage.
|
|
116
|
+
|
|
117
|
+
## RULE 5 - Stop what holds the files, or refuse
|
|
118
|
+
|
|
119
|
+
A running service turns a delete into a **partial** delete - the worst outcome.
|
|
120
|
+
|
|
121
|
+
Stop named services first. Then check for surviving processes and **refuse rather
|
|
122
|
+
than force**:
|
|
123
|
+
|
|
124
|
+
```powershell
|
|
125
|
+
$held = @(Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
|
126
|
+
Where-Object { $_.ExecutablePath -like "$venv\*" })
|
|
127
|
+
if ($held.Count) { throw "refusing: still held by PID $($held[0].ProcessId)" }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In the incident's repair, this refused on one repo because a **VS Code language
|
|
131
|
+
server** had held that venv since the previous day. Not a service, not safe to
|
|
132
|
+
kill, not the script's business. That repo was fixed a different way. **Refusing
|
|
133
|
+
was correct.**
|
|
134
|
+
|
|
135
|
+
## RULE 6 - One agent at a time on a shared resource
|
|
136
|
+
|
|
137
|
+
Two agents installing into the same environment concurrently can produce exactly
|
|
138
|
+
this partial state. Three sessions were live in one repository during the
|
|
139
|
+
incident. Whoever is repairing says so; everyone else keeps out.
|
|
140
|
+
|
|
141
|
+
## RULE 7 - Verify against the source of truth, not the working copy
|
|
142
|
+
|
|
143
|
+
Checkouts go stale. During the same incident a subagent reported a file as absent
|
|
144
|
+
from a configuration, contradicting a change made hours earlier. The file was
|
|
145
|
+
present on `origin/main`; the working copy was **10 commits behind**.
|
|
146
|
+
|
|
147
|
+
Before asserting something is missing, check `git show origin/main:<path>`, not
|
|
148
|
+
the file on disk.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Before you delete anything - the checklist
|
|
153
|
+
|
|
154
|
+
1. Can I **rename** instead? Do that.
|
|
155
|
+
2. Does the tree contain **reparse points**? Scan with `-ErrorAction Stop`. Refuse if any, or if the scan fails.
|
|
156
|
+
3. Does anything **hold files open**? Stop services; refuse on surviving processes.
|
|
157
|
+
4. Did I create any **links into live directories** earlier in this session? Unlink them explicitly, by name.
|
|
158
|
+
5. After: **verify by using**, not by counting.
|
|
159
|
+
6. If something looks wrong: check `origin/main`, not the working copy.
|
|
160
|
+
|
|
161
|
+
## What good verification output looks like
|
|
162
|
+
|
|
163
|
+
Not this:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
.venv intact: True <- only proves python.exe exists
|
|
167
|
+
31 unlinked, 0 failed <- says nothing about what the scan missed
|
|
168
|
+
No broken requirements found <- reads metadata, not reality
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
service-a OK declared=24 missing=0 <- every declared dep imported
|
|
175
|
+
service-b OK declared=19 missing=0
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Reference implementation
|
|
179
|
+
|
|
180
|
+
The estate this skill came from keeps working tools at `.caddis/tools/`:
|
|
181
|
+
|
|
182
|
+
- `venv-audit.py` - imports every dependency each `pyproject.toml` declares
|
|
183
|
+
- `rebuild-venv.ps1` - renames aside, rebuilds, restarts services, refuses on links or live processes
|
|
184
|
+
- `README-venv-safety.md` - the incident record
|
|
185
|
+
|
|
186
|
+
Adapt rather than copy: the rules matter more than the scripts.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: safe-cleanup
|
|
3
|
+
description: Use BEFORE deleting any directory tree, clearing a scratchpad, removing git worktrees, or "tidying up" generated folders - especially on Windows, where a recursive delete that meets a junction or symlink destroys the link's TARGET. Also use when verifying that a Python venv or node_modules survived a cleanup, because pip check and file counts both report healthy on a half-deleted environment.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Safe Cleanup
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Cleanup looks like the safest thing you do all day. It is the most destructive.
|
|
11
|
+
|
|
12
|
+
This skill exists because of a real incident: an agent created 31 directory
|
|
13
|
+
junctions from throwaway worktrees into live repositories, unlinked them, saw
|
|
14
|
+
"31 unlinked, 0 failed", then recursively deleted the parent. A junction it had
|
|
15
|
+
never seen survived the scan, the delete followed it, and **five repositories'
|
|
16
|
+
development environments were destroyed**. Every verification afterwards said
|
|
17
|
+
"intact", because the verification was wrong too.
|
|
18
|
+
|
|
19
|
+
Two hours of repair. None of it would have happened with the rules below.
|
|
20
|
+
|
|
21
|
+
## The one-paragraph version
|
|
22
|
+
|
|
23
|
+
On Windows, `Remove-Item -Recurse` **follows junctions and deletes their
|
|
24
|
+
targets**. Enumerating with `-ErrorAction SilentlyContinue` hides the ones you
|
|
25
|
+
cannot read. And after the damage, `pip check`, `pip list` and file counts all
|
|
26
|
+
report success, because metadata survives while package files do not. So: never
|
|
27
|
+
link into somewhere real, never recurse through a link, prefer rename over
|
|
28
|
+
delete, and verify by *using* the thing rather than counting it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## RULE 1 - Prefer rename over delete
|
|
33
|
+
|
|
34
|
+
Rename is atomic. Recursive delete is not: it can fail half way and leave a
|
|
35
|
+
directory that looks present and is unusable.
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
Rename-Item -Path $target -NewName ".broken-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
In the incident, a venv holding `_greenlet.cp314-win_amd64.pyd` open **refused to
|
|
42
|
+
delete** but **renamed fine**. Had the delete been retried or forced, it would
|
|
43
|
+
have half-emptied the directory.
|
|
44
|
+
|
|
45
|
+
Rename first, build fresh alongside, delete the old copy later when nothing holds
|
|
46
|
+
it. Disk is cheap. An environment nobody can recover is not.
|
|
47
|
+
|
|
48
|
+
## RULE 2 - Never recurse a delete through a reparse point
|
|
49
|
+
|
|
50
|
+
Junctions and symlinks are transparent to `Remove-Item -Recurse`. Deleting a
|
|
51
|
+
folder that contains a link to `C:\live\thing` deletes **the contents of
|
|
52
|
+
`C:\live\thing`**.
|
|
53
|
+
|
|
54
|
+
Check first, and **fail if the check itself cannot complete**:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
$links = @(Get-ChildItem $path -Recurse -Force -Directory -ErrorAction Stop |
|
|
58
|
+
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
59
|
+
if ($links.Count) {
|
|
60
|
+
$links | ForEach-Object { Write-Host " $($_.FullName) -> $($_.Target)" }
|
|
61
|
+
throw "refusing: $($links.Count) link(s) inside $path"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`-ErrorAction SilentlyContinue` on that scan is precisely what caused the
|
|
66
|
+
incident. A link inside a directory the scanner could not read was skipped in
|
|
67
|
+
silence and never appeared in the count. **A scan that cannot see everything must
|
|
68
|
+
fail, not shrug.**
|
|
69
|
+
|
|
70
|
+
To remove a link itself, unlink it - never delete through it:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
[System.IO.Directory]::Delete($linkPath, $false) # link only, never the target
|
|
74
|
+
cmd /c rmdir "$linkPath" # same effect
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## RULE 3 - Do not create links into live directories
|
|
78
|
+
|
|
79
|
+
The safest delete is the one with nothing to follow.
|
|
80
|
+
|
|
81
|
+
If a throwaway workspace needs dependencies, **build them there**, or point the
|
|
82
|
+
tool at an absolute interpreter path. Do not junction `<scratch>\.venv` to
|
|
83
|
+
`<live-repo>\.venv` for convenience - that is a loaded gun aimed at the repo, and
|
|
84
|
+
any cleanup above the link can fire it.
|
|
85
|
+
|
|
86
|
+
If you must link, remove the link the moment you are done, and never leave one
|
|
87
|
+
inside a directory that something else will clean up.
|
|
88
|
+
|
|
89
|
+
## RULE 4 - Verify by using, not by counting
|
|
90
|
+
|
|
91
|
+
After any cleanup near an environment, prove it still works. These all LIE on a
|
|
92
|
+
half-deleted Python venv, because dist-info metadata survives while package files
|
|
93
|
+
are removed:
|
|
94
|
+
|
|
95
|
+
| Check | Why it lies |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `Test-Path .venv\Scripts\python.exe` | Proves the interpreter exists. Says nothing about packages. |
|
|
98
|
+
| file or directory counts | Metadata directories inflate the count. |
|
|
99
|
+
| `pip list` | Reads metadata. |
|
|
100
|
+
| `pip check` | Reads metadata. Reported "No broken requirements found" on a venv that could not import fastapi. |
|
|
101
|
+
| `pip install -e .` | Reads metadata, calls the package satisfied, **skips it, and reports success**. |
|
|
102
|
+
|
|
103
|
+
The honest check is whether the interpreter can import what the project declares:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import importlib.util
|
|
107
|
+
importlib.util.find_spec("fastapi") is None # -> True means genuinely missing
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For node, check that a real entry point resolves, not that `node_modules` exists.
|
|
111
|
+
|
|
112
|
+
**Map distribution names to import names.** `pyjwt` imports as `jwt`,
|
|
113
|
+
`python-ulid` as `ulid`, `python-dotenv` as `dotenv`. A naive audit produces false
|
|
114
|
+
positives and will have you rebuilding healthy environments - which is its own way
|
|
115
|
+
of causing an outage.
|
|
116
|
+
|
|
117
|
+
## RULE 5 - Stop what holds the files, or refuse
|
|
118
|
+
|
|
119
|
+
A running service turns a delete into a **partial** delete - the worst outcome.
|
|
120
|
+
|
|
121
|
+
Stop named services first. Then check for surviving processes and **refuse rather
|
|
122
|
+
than force**:
|
|
123
|
+
|
|
124
|
+
```powershell
|
|
125
|
+
$held = @(Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
|
126
|
+
Where-Object { $_.ExecutablePath -like "$venv\*" })
|
|
127
|
+
if ($held.Count) { throw "refusing: still held by PID $($held[0].ProcessId)" }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In the incident's repair, this refused on one repo because a **VS Code language
|
|
131
|
+
server** had held that venv since the previous day. Not a service, not safe to
|
|
132
|
+
kill, not the script's business. That repo was fixed a different way. **Refusing
|
|
133
|
+
was correct.**
|
|
134
|
+
|
|
135
|
+
## RULE 6 - One agent at a time on a shared resource
|
|
136
|
+
|
|
137
|
+
Two agents installing into the same environment concurrently can produce exactly
|
|
138
|
+
this partial state. Three sessions were live in one repository during the
|
|
139
|
+
incident. Whoever is repairing says so; everyone else keeps out.
|
|
140
|
+
|
|
141
|
+
## RULE 7 - Verify against the source of truth, not the working copy
|
|
142
|
+
|
|
143
|
+
Checkouts go stale. During the same incident a subagent reported a file as absent
|
|
144
|
+
from a configuration, contradicting a change made hours earlier. The file was
|
|
145
|
+
present on `origin/main`; the working copy was **10 commits behind**.
|
|
146
|
+
|
|
147
|
+
Before asserting something is missing, check `git show origin/main:<path>`, not
|
|
148
|
+
the file on disk.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Before you delete anything - the checklist
|
|
153
|
+
|
|
154
|
+
1. Can I **rename** instead? Do that.
|
|
155
|
+
2. Does the tree contain **reparse points**? Scan with `-ErrorAction Stop`. Refuse if any, or if the scan fails.
|
|
156
|
+
3. Does anything **hold files open**? Stop services; refuse on surviving processes.
|
|
157
|
+
4. Did I create any **links into live directories** earlier in this session? Unlink them explicitly, by name.
|
|
158
|
+
5. After: **verify by using**, not by counting.
|
|
159
|
+
6. If something looks wrong: check `origin/main`, not the working copy.
|
|
160
|
+
|
|
161
|
+
## What good verification output looks like
|
|
162
|
+
|
|
163
|
+
Not this:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
.venv intact: True <- only proves python.exe exists
|
|
167
|
+
31 unlinked, 0 failed <- says nothing about what the scan missed
|
|
168
|
+
No broken requirements found <- reads metadata, not reality
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
service-a OK declared=24 missing=0 <- every declared dep imported
|
|
175
|
+
service-b OK declared=19 missing=0
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Reference implementation
|
|
179
|
+
|
|
180
|
+
The estate this skill came from keeps working tools at `.caddis/tools/`:
|
|
181
|
+
|
|
182
|
+
- `venv-audit.py` - imports every dependency each `pyproject.toml` declares
|
|
183
|
+
- `rebuild-venv.ps1` - renames aside, rebuilds, restarts services, refuses on links or live processes
|
|
184
|
+
- `README-venv-safety.md` - the incident record
|
|
185
|
+
|
|
186
|
+
Adapt rather than copy: the rules matter more than the scripts.
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED