slopkeeper 0.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of slopkeeper might be problematic. Click here for more details.

@@ -0,0 +1,305 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Greg Brandt <brandt.greg@gmail.com>
3
+
4
+ SPDX-License-Identifier: MIT
5
+ -->
6
+
7
+ # AGENT_GUIDE — how to drive SlopKeeper
8
+
9
+ You are the calling agent. The user said something like "organize my
10
+ directory" or "clean up my workspace". This tool is your hands and your
11
+ seatbelt: it inventories, validates, moves and scaffolds. You classify.
12
+ The human approves. **Never generate or execute a plan without showing the
13
+ rendered markdown to the human first.**
14
+
15
+ (Humans can also drive slopkeeper themselves: bare `slopkeeper` with no subcommand
16
+ starts an interactive `/command` REPL. That is not your surface — drive
17
+ the JSON subcommands documented here; they are scriptable and stable.)
18
+
19
+ ## Hard rules
20
+
21
+ 1. **No plan, no moves.** Every file operation goes through a validated
22
+ plan JSON. There is no other write path.
23
+ 2. **Dry-run is the default.** `apply` and `scaffold` and `undo` do nothing
24
+ destructive without `--execute`.
25
+ 3. **The human gate.** Render the plan (`plan render`) and present it.
26
+ The human edits the plan JSON or approves. Re-validate after any edit.
27
+ 4. **Respect reference risks.** `reference_risks` lists files cited from
28
+ files that are NOT moving. Prefer declaring the rewrite IN the plan
29
+ (`edits: [{file, find, replace}]` — applied after the moves, journaled,
30
+ undo-able; `edit.file` is the move-source path if the file moves,
31
+ else its current path, and `find` must occur exactly once). Otherwise:
32
+ move the citing files too, or get explicit human sign-off on the
33
+ breakage. Never silently accept. Citations from OUTSIDE the corpus
34
+ (skills, cron scripts, scratch harnesses) are never listed there —
35
+ apply scans the corpus only; sweep the directories that cite your
36
+ corpus with `refs --scan-path` (see "Authoring reference rewrites").
37
+ Recall limits: the scan is not exhaustive — basenames under 3
38
+ characters are never scanned; a citation from a file that shares the
39
+ cited file's basename is skipped (attribution would be ambiguous);
40
+ extensionless files and non-text extensions are never read. So sweep
41
+ critical directories with `slopkeeper refs --scan-path` too.
42
+ 5. **Unresolved is honest.** Files you can't classify go in
43
+ `unresolved` with a reason — never guess. `keep` is for top-level
44
+ entries that stay (e.g. `docs/`, a watchlist).
45
+ 6. **Corpus changed?** If apply raises `corpus_changed`, re-inventory and
46
+ regenerate the plan. `--force` only if the changes were yours and
47
+ expected. Staleness is content-based: a bare `touch` won't force a
48
+ re-plan, but a real edit — even one that keeps the byte size — will.
49
+
50
+ ## The protocol
51
+
52
+ ```
53
+ 1. slopkeeper doctor <root>
54
+ → strategy suggestion, token estimate, advice. Read it.
55
+
56
+ 2. slopkeeper inventory <root> [--fast --summary | --out inv.json]
57
+ → <800 files: full JSON with heads + cross-references (read it whole)
58
+ >800 files: --fast --summary, then per-dir inventories as needed
59
+
60
+ 3. slopkeeper clusters <root>
61
+ → similarity HINTS: content clusters, scrape pairs, dupes, hubs.
62
+ Name each cluster, merge/split by judgment. Never apply a cluster
63
+ as-is; and check `hubs` before moving a file cited by many others.
64
+
65
+ 4. slopkeeper strategies show <suggested-id> (or your choice)
66
+ → the partitioning rubric. Apply it. Strategies:
67
+ role-bots | topics | projects | hybrid (default for mixed corpora)
68
+
69
+ 5. Write the plan JSON. Get the template:
70
+ slopkeeper plan schema
71
+ Record the inventory's fingerprint as based_on.corpus_fingerprint.
72
+ Cover every top-level entry: moves | keep | unresolved.
73
+ (keep accepts "name" or {"path": "name", "reason": "..."}.)
74
+
75
+ 6. slopkeeper plan validate <plan.json> <root>
76
+ → fix every error code it gives you. Then:
77
+ slopkeeper plan render <plan.json> <root> --out plan.md
78
+ → HUMAN GATE: show plan.md. Wait for approval.
79
+
80
+ 7. slopkeeper apply <plan.json> <root> (dry-run)
81
+ slopkeeper apply <plan.json> <root> --execute (for real)
82
+ → note reference_risks; do rewrite steps now (plain edits, no tool).
83
+ A plan whose bytes differ from the last `plan render --out`
84
+ (e.g. hand-edited JSON) gets a `render_warning` in the result —
85
+ informational only: re-render or get explicit sign-off if the
86
+ human approved the earlier render.
87
+
88
+ 8. slopkeeper scaffold <plan.json> <root> --execute
89
+ → writes AGENTS.md roster + per-folder AGENTS.md/STATE.md.
90
+ Existing files are never overwritten; you get *.slopkeeper-suggested
91
+ to merge by hand.
92
+
93
+ 9. Report: moved counts, risk resolutions, suggested-file merges needed,
94
+ journal path (for undo). Offer: slopkeeper undo <journal> <root>
95
+ (dry-run is the default; add --execute to actually restore)
96
+
97
+ PARTIAL APPLY: `apply --execute` can fail MID-RUN (an OSError on one
98
+ hop — permissions, disk full). The envelope then carries
99
+ "partial": true, the process exits non-zero, and next.command is
100
+ the exact undo for this journal. Read `errors`, fix the cause, run
101
+ the suggested `slopkeeper undo <journal> <root>` (dry-run is the default;
102
+ add --execute to actually restore), then re-apply. A source that
103
+ vanishes mid-apply is the same path: it lands in `errors` and the
104
+ run exits non-zero — never silent. Earlier hops stay journaled and
105
+ undoable throughout.
106
+ ```
107
+
108
+ ## Re-tending (the recurring loop)
109
+
110
+ After the first sweep, `apply --execute` and `scaffold --execute` both
111
+ write `.slopkeeper/manifest.json`. From then on, a tending pass costs
112
+ tokens proportional to the slop shot in, not the corpus size:
113
+
114
+ ```
115
+ 1. slopkeeper status <root>
116
+ → new_files (with title+head: triage them without opening anything),
117
+ modified, removed, stale folders, still-unresolved items.
118
+
119
+ 2. slopkeeper draft <root> --out draft-plan.json
120
+ → OPTIONAL but usually the fastest path: auto-drafts a PARTIAL plan
121
+ by nearest filed neighbor (TF-IDF cosine). Confident files get a
122
+ move with the neighbor + score as the reason; everything else is
123
+ unresolved WITH the reason ("best neighbor only 0.03"). Draft is
124
+ a guess with a citation — review every move, then edit.
125
+ The stdout envelope carries COUNTS only ({drafted_count,
126
+ unresolved_count, new_count, undrafted, out_path}) — the plan
127
+ body lives only in the --out file. The default path resolves
128
+ against your CWD and is REFUSED if it lands inside the corpus
129
+ (`draft_out_inside_root`) — a draft written into the root would
130
+ shift its fingerprint and the next apply would refuse with
131
+ corpus_changed. When new files exceed what the draft could see
132
+ (status's 2000-new_files cap), `undrafted` carries the count and
133
+ the reason, and the next-step hint says so. A zero-move draft
134
+ (everything went unresolved) is still a valid plan: validate it
135
+ and apply --execute — the backlog is recorded for the human.
136
+
137
+ 3. Triage. Fix the draft: reclassify wrong moves, resolve the
138
+ unresolved ones (they're the actual judgment work), add folder
139
+ entries for any new folder the roster needs.
140
+
141
+ 4. apply --execute → scaffold --execute (both record the manifest)
142
+ ```
143
+
144
+ Notes:
145
+ - A delta plan should carry `"partial": true` at the top level (draft
146
+ plans already do) — no --allow-partial flags needed.
147
+ - `still_unresolved` in the status output is the human-decides backlog;
148
+ surface it every tend until it's empty.
149
+ - `skipped_bad_unresolved` counts backlog entries whose `path` was
150
+ null or missing — they are skipped (state-file content is data,
151
+ never a crash); the next re-record drops them from the manifest and
152
+ records the same count there.
153
+ - `modified` files are leads, not chores: a changed STATE.md or AGENTS.md
154
+ is the owner tending by hand — leave it alone; a changed runbook may
155
+ deserve a fresh look in your triage note.
156
+
157
+ ## Tending several corpora on a schedule
158
+
159
+ Register the corpora you tend once; the registry is the loop's to-do
160
+ list, so a scheduled job is one call instead of a shell script. It lives
161
+ at `$XDG_CONFIG_HOME/slopkeeper/roots.json` (set `SLOPKEEPER_HOME` to
162
+ relocate it, e.g. for tests); content is sorted, unique, absolute paths.
163
+
164
+ ```
165
+ slopkeeper roots add <dir> → register a corpus (idempotent; must exist)
166
+ slopkeeper roots remove <dir> → unregister (the corpus itself is untouched)
167
+ slopkeeper roots list → what is registered
168
+
169
+ slopkeeper status --all → ONE digest over every registered root:
170
+ per root {root, ok, never_tended, new_count, modified_count,
171
+ removed_count, stale_folders, unresolved_count}
172
+ ```
173
+
174
+ The digest is counts, not listings — keep it skimmable, then drill into
175
+ whichever roots are nonzero with the ordinary per-root `status`. A
176
+ deleted root, a never-tended corpus, or a root whose status fails is a
177
+ row (`ok: false` carries the error), never a failed run — the other
178
+ roots always report. `--all` with an empty registry is a clean error:
179
+ add roots first.
180
+
181
+ ## Authoring reference rewrites
182
+
183
+ When a move risks breaking citations from stay-put files:
184
+
185
+ ```
186
+ 1. slopkeeper refs <root> <basename>
187
+ → exact citation sites: {file, line, text}.
188
+
189
+ 2. Citations also live OUTSIDE the corpus — skills, AGENT_GUIDE-style
190
+ guides, cron scripts, scratch harnesses. reference_risks never lists
191
+ them (apply scans the corpus only), so sweep the dirs that cite your
192
+ corpus explicitly:
193
+
194
+ slopkeeper refs <root> <basename> --scan-path ~/skills --scan-path ~/cron
195
+ → same {file, line, text} sites; every hit outside the corpus
196
+ carries "external": true and an absolute `file`. Dot-directories
197
+ and non-text files are skipped; the same per-file cap and size
198
+ limit as the in-tree scan apply (corpus_too_big refuses an
199
+ oversized dir, scan_path_missing a bad one).
200
+
201
+ 3. Add to the plan:
202
+ "edits": [{"file": "<citing file (move-source path if it moves)>",
203
+ "find": "<exact text from the citation line, unique>",
204
+ "replace": "<new path after the move>",
205
+ "reason": "fix citation"}]
206
+ find must occur exactly once in the file (edit_ambiguous otherwise);
207
+ if the content drifted, you get edit_stale. The target must be valid
208
+ UTF-8 (edit_not_utf8 otherwise) — non-UTF-8 files are refused, never
209
+ re-encoded. Edits run after the moves,
210
+ are journaled, and undo reverses them.
211
+
212
+ 4. To rewrite a citation in a file OUTSIDE the corpus, declare the
213
+ directory it lives under and use an ABSOLUTE edit path:
214
+
215
+ "external_roots": ["/abs/dir"],
216
+ "edits": [{"file": "/abs/dir/harness.sh",
217
+ "find": "tmp/docs/infra/config.yaml",
218
+ "replace": "docs/infra/config.yaml",
219
+ "reason": "external citation"}]
220
+
221
+ Every external_roots entry must be an absolute, existing directory
222
+ (plan_bad_external_root otherwise). With no external_roots declared,
223
+ an edit outside the root is rejected (path_escape) — the old rule,
224
+ unchanged. External edits run after the in-root ones, are journaled
225
+ under their absolute path, and undo reverses them. `plan render`
226
+ marks external edits so the human sees writes outside the corpus.
227
+ ```
228
+
229
+ A citing file covered by an edit no longer counts as a reference risk,
230
+ so `--strict-references` is usable when all rewrites are declared —
231
+ in-root and external alike.
232
+
233
+ ## Error codes you will see
234
+
235
+ | code | meaning | your move |
236
+ |---|---|---|
237
+ | `plan_incomplete` | top-level entries uncovered | add to moves/keep/unresolved |
238
+ | `plan_no_moves` | `moves` is not a list, or is empty on a non-partial plan | a plan needs at least one move — unless it carries `"partial": true` (a zero-move partial plan is valid: the draft filed everything as unresolved; validate it and `apply --execute` records the backlog) |
239
+ | `plan_stale_src` | a source vanished | re-inventory, regenerate |
240
+ | `corpus_changed` | fingerprint mismatch | re-inventory; `--force` only if yours |
241
+ | `dst_exists` | destination occupied | resolve collision in plan |
242
+ | `referenced_moves` | strict mode: refs would break | rewrite refs first |
243
+ | `plan_nested_moves` | dir + its contents both moved | move dir whole OR its files |
244
+ | `plan_bad_keep` | keep entry is neither string nor {path} object | use `"name"` or `{"path": "name", "reason": "..."}` |
245
+ | `plan_bad_unresolved` | an unresolved entry has no non-empty string `path` (null, missing, or blank) | every entry is `{"path": "<file>", "reason": "<why it waits>"}` — fix or drop it |
246
+ | `edit_stale` | edit target missing or find-text gone | update the edit in the plan |
247
+ | `edit_ambiguous` | find text occurs more than once | make find unique; split the edit |
248
+ | `edit_not_utf8` | edit target is not valid UTF-8 (e.g. latin-1); editing would corrupt it | convert the file to UTF-8 first, or rewrite the citation by hand |
249
+ | `plan_bad_external_root` | external_roots entry is not an absolute, existing directory | create or fix the directory, or omit external_roots |
250
+ | `scan_path_missing` | `refs --scan-path` directory does not exist | create it or fix the path |
251
+ | `no_manifest` | draft with no prior tend | run a first tend first |
252
+ | `draft_out_inside_root` | `draft --out` resolves inside the corpus root — a draft written into the corpus shifts its fingerprint (the next apply would fail `corpus_changed`) | write the draft outside the corpus: run from another directory (the default `draft-plan.json` resolves against your CWD) or pass an explicit path elsewhere |
253
+ | `path_escape` | path outside root, contains a null byte, or is the root itself | use relative paths to real files; for edits outside the root, declare `external_roots` |
254
+ | `manifest_unreadable` / `manifest_bad_schema` | `.slopkeeper/manifest.json` corrupt | delete it; `status --record` rebuilds |
255
+ | `tending_in_progress` | another tend holds `.slopkeeper/tend.lock` (execute-mode apply/scaffold/undo, `status --record`) | wait for the other tend, or check for a dead agent's session; the OS frees the lock on process death |
256
+ | `root_not_dir` | `roots add` target is not an existing directory | register corpora that exist; SlopKeeper doesn't create them |
257
+ | `root_not_registered` | `roots remove` for a path not in the registry | check `slopkeeper roots list` |
258
+ | `root_missing` (in `status --all` data) | a registered root is no longer a directory | re-add its new path, or `roots remove` if it's gone |
259
+ | `roots_unreadable` / `roots_bad_schema` | roots registry file corrupt/foreign | delete it; re-add roots |
260
+ | `no_roots` | `status --all` with an empty registry | `roots add` first, or run `status <root>` directly |
261
+ | `no_root` | `status` with neither a root nor `--all` | name a root or use `--all` |
262
+ | `similarity_cap` | clusters handed more than 3000 files (the O(n²) matcher would crawl) | partition per-directory: `slopkeeper clusters <root>/<subdir>`, or organize smaller scopes |
263
+ | `plan_not_json` | plan file is not valid JSON | fix the JSON syntax and re-validate |
264
+ | `plan_unreadable` | plan file cannot be read | check the path exists and is readable |
265
+ | `plan_not_object` | plan JSON parses but is not an object | the plan is a JSON object with `schema` and `moves` — not a bare list |
266
+ | `plan_missing_field` | plan is missing a required field | add it; see `slopkeeper plan schema` |
267
+ | `plan_bad_schema` | `plan.schema` is not the current schema | regenerate from the current template: `slopkeeper plan schema` |
268
+ | `plan_wrong_root` | the plan's `corpus_root` names a different corpus | re-target or regenerate the plan for this root |
269
+ | `plan_bad_move` | a move lacks `from`/`to` | every move is `{"from": "...", "to": "..."}` |
270
+ | `plan_noop_move` | a move's `from` equals its `to` | drop the no-op move |
271
+ | `plan_dup_src` | two moves share a source | one move per file |
272
+ | `plan_dup_dst` | two moves share a destination | resolve the collision — chains (a→b, b→c) are fine, two files onto one path are not |
273
+ | `plan_dst_under_src` | a destination is inside another move's source | move the directory whole, or flatten the plan |
274
+ | `plan_bad_edit` | an edit lacks `file`/`find`/`replace` | every edit carries all three; see `slopkeeper plan schema` |
275
+ | `plan_bad_folder` | a folder entry has no `path` | every folder entry names a path |
276
+ | `plan_bad_kind` | folder `kind` is unknown | kind is one of bot, case, topic, project, archive, scratch |
277
+ | `move_cycle` | the moves form a cycle (a→b→a) | remove the circular moves from the plan |
278
+ | `symlink_escape` | a path resolves outside the root via a symlink | remove the symlink or move its target explicitly |
279
+ | `no_corpus` | the argument to `doctor` is not a directory | pass the directory you want to organize |
280
+ | `no_plan` | REPL: no compiled plan to act on (`/apply`/`/render`/`/scaffold` before `/plan`, or decisions changed since) | `/plan` first — it compiles decisions, validates and saves; `/load` + `/plan` for an agent-authored plan |
281
+ | `no_such_move` | REPL: not a move number | `/moves` lists the working plan's moves |
282
+ | `no_basename` | REPL: `/refs` called with no basename | `/refs <basename>` |
283
+ | `no_path` | REPL: a required path argument is missing or empty | see the usage line in the message |
284
+ | `no_such_journal` | REPL: not a journal index (or a non-numeric one) | `/undo` lists journals; `/undo <n> [!]` |
285
+ | `cat_unreadable` | REPL: the `/cat` target cannot be read | check the path (it is relative to the corpus root) |
286
+ | `edit_unreadable` | an edit target cannot be read | check the file exists and is readable |
287
+ | `journal_unreadable` | the journal file cannot be read | check the journal path |
288
+ | `journal_empty` | the journal contains no entries | nothing to undo |
289
+ | `corpus_too_big` | a corpus or scan dir exceeds its cap (2000 for the refs scan, 3000 filed files for draft) | partition per-directory, scan a narrower dir, or grep by hand |
290
+ | `unknown_strategy` | no strategy with that id | `slopkeeper strategies` for the available ids |
291
+
292
+ ## Sizing
293
+
294
+ - Inventory: at ≤ 800 files the full JSON (heads + cross-references)
295
+ is one read — read it whole. Above that a full read is too big
296
+ (~1000 files ≈ 150k tokens): use `--fast --summary`, then
297
+ `inventory <subdir>` for the dirs you plan to reorganize, plus
298
+ title/head from the summary. Inventory refuses past 8000 files.
299
+ - Reference scanning: apply and `refs` scan at most 2000 files — past
300
+ that the result is `reference_scan: "skipped_corpus_too_big"`,
301
+ never a silent empty risk set. (The inventory's embedded
302
+ cross-reference scan only runs at ≤ 800 files.)
303
+ - Clusters and draft: 3000 files. Past it `clusters` refuses with
304
+ `similarity_cap` (draft: `corpus_too_big`) — partition
305
+ per-directory instead.
slopkeeper/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2026 Greg Brandt <brandt.greg@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """slopkeeper — inventory, partition and scaffold messy file corpora.
6
+
7
+ Agent-first CLI: the calling agent is the brain; this tool is the hands
8
+ and the seatbelt. No LLM calls, no network, stdlib only.
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+ VERSION = __version__
slopkeeper/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Greg Brandt <brandt.greg@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from .cli import main
6
+ import sys
7
+
8
+ sys.exit(main())