glitch-toolkit 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.
glitch/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Bounded autonomy for agents working in a repository.
2
+
3
+ Six practices from *Building Your Store Or Your SaaS With Claude*, installed
4
+ into your own project and checked there. The checker's contract is that an
5
+ artifact counts as installed only when it is present, runs, AND still refuses a
6
+ deliberately broken case; anything that merely passes is treated as not wired up.
7
+
8
+ The command line is the product. Import it if you want to, but nothing in here
9
+ is a stable API yet.
10
+ """
11
+
12
+ __version__ = "0.1.0"
glitch/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow `python -m glitch` alongside the `glitch` console script."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,219 @@
1
+ """Lane claims for a repo that several Claude sessions share.
2
+
3
+ The failure this exists for: two sessions edit the same file, the second write
4
+ wins, there is no conflict and no error, and the lost work reads to you as a
5
+ completed task. You find out days later, or never.
6
+
7
+ Claims live one-file-per-lane under .claude/lanes/ so two sessions writing
8
+ claims at the same moment never touch the same file. A shared claims file would
9
+ race, which is the bug we are here to avoid.
10
+
11
+ python check_lanes.py claim backend app/main.py app/routes/*.py
12
+ python check_lanes.py check app/main.py # before you edit
13
+ python check_lanes.py status
14
+ python check_lanes.py release backend
15
+
16
+ Honest limits, stated up front:
17
+ * This cannot see other sessions. It sees claims they wrote and mtimes on
18
+ disk. A session that never claims anything is invisible to it.
19
+ * mtime evidence is advisory. A recent mtime means someone touched the file,
20
+ not necessarily that they are still in it.
21
+ * It does not lock anything. It tells you; you decide.
22
+ """
23
+ import argparse
24
+ import fnmatch
25
+ import glob
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import socket
30
+ import sys
31
+ import time
32
+
33
+ LANES_DIR = pathlib.Path(".claude/lanes")
34
+ STALE_HOURS = 4.0 # a claim older than this is suspect, see status output
35
+ RECENT_EDIT_MINUTES = 30 # mtime inside this window is worth warning about
36
+
37
+
38
+ def _now() -> float:
39
+ return time.time()
40
+
41
+
42
+ def _lane_files() -> list[pathlib.Path]:
43
+ return sorted(LANES_DIR.glob("*.json")) if LANES_DIR.exists() else []
44
+
45
+
46
+ def _load(p: pathlib.Path) -> dict | None:
47
+ try:
48
+ return json.loads(p.read_text(encoding="utf-8"))
49
+ except (OSError, json.JSONDecodeError):
50
+ return None
51
+
52
+
53
+ def _expand(patterns: list[str]) -> list[str]:
54
+ """Globs are resolved at claim time so a claim names real files."""
55
+ out = []
56
+ for pat in patterns:
57
+ hits = glob.glob(pat, recursive=True)
58
+ out.extend(hits if hits else [pat])
59
+ return sorted({os.path.normpath(p).replace("\\", "/") for p in out})
60
+
61
+
62
+ def _matches(claimed: str, target: str) -> bool:
63
+ t = os.path.normpath(target).replace("\\", "/")
64
+ return t == claimed or fnmatch.fnmatch(t, claimed) or t.startswith(claimed.rstrip("/") + "/")
65
+
66
+
67
+ def _age(ts: float) -> str:
68
+ m = (_now() - ts) / 60
69
+ if m < 60:
70
+ return f"{m:.0f}m ago"
71
+ return f"{m / 60:.1f}h ago"
72
+
73
+
74
+ def cmd_claim(args) -> int:
75
+ LANES_DIR.mkdir(parents=True, exist_ok=True)
76
+ paths = _expand(args.paths)
77
+
78
+ conflicts = _conflicts(paths, skip_lane=args.lane)
79
+ if conflicts and not args.force:
80
+ print("REFUSED. Another lane already claims these:")
81
+ for path, lane, ts in conflicts:
82
+ print(f" {path} -> lane '{lane}' (claimed {_age(ts)})")
83
+ print("\nTalk to that lane, or re-run with --force if you know it is stale.")
84
+ return 1
85
+
86
+ dest = LANES_DIR / f"{args.lane}.json"
87
+ dest.write_text(json.dumps({
88
+ "lane": args.lane,
89
+ "paths": paths,
90
+ "claimed_at": _now(),
91
+ "host": socket.gethostname(),
92
+ "pid": os.getpid(),
93
+ "note": args.note or "",
94
+ }, indent=2), encoding="utf-8")
95
+
96
+ print(f"claimed {len(paths)} path(s) for lane '{args.lane}'")
97
+ for p in paths:
98
+ print(f" {p}")
99
+ if conflicts:
100
+ print("\nforced past these conflicts:")
101
+ for path, lane, _ in conflicts:
102
+ print(f" {path} (lane '{lane}')")
103
+ return 0
104
+
105
+
106
+ def _conflicts(paths: list[str], skip_lane: str | None) -> list[tuple[str, str, float]]:
107
+ found = []
108
+ for lf in _lane_files():
109
+ data = _load(lf)
110
+ if not data or data.get("lane") == skip_lane:
111
+ continue
112
+ for claimed in data.get("paths", []):
113
+ for target in paths:
114
+ if _matches(claimed, target) or _matches(target, claimed):
115
+ found.append((target, data["lane"], data.get("claimed_at", 0)))
116
+ return found
117
+
118
+
119
+ def cmd_check(args) -> int:
120
+ paths = _expand(args.paths)
121
+ problems = 0
122
+
123
+ conflicts = _conflicts(paths, skip_lane=args.lane)
124
+ if conflicts:
125
+ problems += len(conflicts)
126
+ print("CLAIMED BY ANOTHER LANE:")
127
+ for path, lane, ts in conflicts:
128
+ stale = " [STALE]" if (_now() - ts) / 3600 > STALE_HOURS else ""
129
+ print(f" {path} -> lane '{lane}' (claimed {_age(ts)}){stale}")
130
+
131
+ # mtime evidence catches the session that never claimed anything.
132
+ cutoff = _now() - RECENT_EDIT_MINUTES * 60
133
+ recent = []
134
+ for p in paths:
135
+ fp = pathlib.Path(p)
136
+ if fp.is_file() and fp.stat().st_mtime > cutoff:
137
+ recent.append((p, fp.stat().st_mtime))
138
+ if recent:
139
+ problems += len(recent)
140
+ print("\nRECENTLY MODIFIED (someone may be in these right now):")
141
+ for p, ts in sorted(recent, key=lambda x: -x[1]):
142
+ print(f" {p} modified {_age(ts)}")
143
+
144
+ if not problems:
145
+ print(f"clear: {len(paths)} path(s), no claims and no recent edits")
146
+ return 0
147
+
148
+ print(f"\n{problems} warning(s). Confirm before editing.")
149
+ return 1
150
+
151
+
152
+ def cmd_status(args) -> int:
153
+ lanes = _lane_files()
154
+ if not lanes:
155
+ print("no lanes claimed")
156
+ return 0
157
+
158
+ print(f"{len(lanes)} lane(s):\n")
159
+ for lf in lanes:
160
+ data = _load(lf)
161
+ if not data:
162
+ print(f" {lf.name}: UNREADABLE")
163
+ continue
164
+ ts = data.get("claimed_at", 0)
165
+ hrs = (_now() - ts) / 3600
166
+ flag = " <- STALE, release it or refresh it" if hrs > STALE_HOURS else ""
167
+ print(f" {data['lane']} ({_age(ts)}, {len(data.get('paths', []))} paths){flag}")
168
+ if data.get("note"):
169
+ print(f" note: {data['note']}")
170
+ for p in data.get("paths", [])[:8]:
171
+ print(f" {p}")
172
+ if len(data.get("paths", [])) > 8:
173
+ print(f" ... and {len(data['paths']) - 8} more")
174
+ print()
175
+
176
+ print("A stale claim is not harmlessly cautious. It makes other lanes route")
177
+ print("around files that are actually free.")
178
+ return 0
179
+
180
+
181
+ def cmd_release(args) -> int:
182
+ dest = LANES_DIR / f"{args.lane}.json"
183
+ if not dest.exists():
184
+ print(f"no claim for lane '{args.lane}'")
185
+ return 1
186
+ dest.unlink()
187
+ print(f"released lane '{args.lane}'")
188
+ return 0
189
+
190
+
191
+ def main() -> int:
192
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
193
+ sub = ap.add_subparsers(dest="cmd", required=True)
194
+
195
+ c = sub.add_parser("claim", help="claim paths for a lane")
196
+ c.add_argument("lane")
197
+ c.add_argument("paths", nargs="+")
198
+ c.add_argument("--note", default="")
199
+ c.add_argument("--force", action="store_true")
200
+ c.set_defaults(fn=cmd_claim)
201
+
202
+ k = sub.add_parser("check", help="check paths before editing")
203
+ k.add_argument("paths", nargs="+")
204
+ k.add_argument("--lane", default=None, help="your own lane, excluded from conflicts")
205
+ k.set_defaults(fn=cmd_check)
206
+
207
+ s = sub.add_parser("status", help="show all claims")
208
+ s.set_defaults(fn=cmd_status)
209
+
210
+ r = sub.add_parser("release", help="release a lane")
211
+ r.add_argument("lane")
212
+ r.set_defaults(fn=cmd_release)
213
+
214
+ args = ap.parse_args()
215
+ return args.fn(args)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.exit(main())
@@ -0,0 +1,341 @@
1
+ """A fleet of chats that can address each other, and a handoff with a fixed shape.
2
+
3
+ python fleet.py desks who exists, who commits
4
+ python fleet.py inbox --me build your unread messages
5
+ python fleet.py send --to build --from ops --subject "..." body on stdin
6
+ python fleet.py handoff --from build --to ops --did "..." --verified "..." --next "..."
7
+ python fleet.py archive --me build mark what you read as read
8
+
9
+ Two problems, and they are the same problem twice.
10
+
11
+ 1. ONE SHARED FILE. Every desk writes its messages into one notes file, two
12
+ desks write at once, and the second write wins silently. That is chapter
13
+ four again, and the fix is the same: never edit a shared file. One file
14
+ per message, created exclusively, so a collision is impossible rather than
15
+ unlikely.
16
+
17
+ 2. AN IMPROVISED HANDOFF. Every chat invents its own way of saying it is
18
+ done, so the next desk gets a paragraph and has to guess what was actually
19
+ verified. `handoff` refuses to write one without all three parts: what was
20
+ done, what was actually run, and what is next.
21
+
22
+ An address that is not a desk is refused. A message sent to a slug nobody
23
+ watches is not delivered anywhere; it sits in a directory no one opens, and the
24
+ sender believes it arrived. So the desk table is the authority, and sending to
25
+ an unknown desk is an error rather than a new directory.
26
+
27
+ WHAT THIS CANNOT DO:
28
+
29
+ * It cannot wake a closed session. A message waits in the inbox until that
30
+ desk opens and checks. Nothing on your machine can make a closed chat read
31
+ its mail, and any tool that claims otherwise is describing a scheduler.
32
+ * It does not know whether a desk did what it said in a handoff. It only
33
+ refuses a handoff that does not say.
34
+ * It is not a queue with delivery guarantees. It is files in directories,
35
+ which is why it survives every session being closed at once.
36
+
37
+ Standard library only. Everything it writes stays under `.claude/fleet/`.
38
+ """
39
+ import argparse
40
+ import errno
41
+ import os
42
+ import pathlib
43
+ import re
44
+ import sys
45
+ import time
46
+
47
+ START = "<!-- desks:start -->"
48
+ END = "<!-- desks:end -->"
49
+ SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
50
+ YES = {"yes", "y", "true"}
51
+ NO = {"no", "n", "false", "-", ""}
52
+
53
+
54
+ class Desk:
55
+ def __init__(self, slug, owns, commits):
56
+ self.slug = slug
57
+ self.owns = owns
58
+ self.commits = commits
59
+
60
+
61
+ def read_desks(path):
62
+ """Parse the desk table. Returns (desks, error)."""
63
+ if not path.is_file():
64
+ return [], (
65
+ "no desk table at {}. A fleet with no written table is a fleet where\n"
66
+ " every desk believes it is allowed to commit.".format(path)
67
+ )
68
+
69
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
70
+ start = end = None
71
+ for i, line in enumerate(lines):
72
+ if START in line and start is None:
73
+ start = i
74
+ elif END in line and start is not None and end is None:
75
+ end = i
76
+ if start is None or end is None:
77
+ return [], "{} has no {} ... {} block".format(path, START, END)
78
+
79
+ desks = []
80
+ for line in lines[start + 1:end]:
81
+ row = line.strip()
82
+ if not row.startswith("|"):
83
+ continue
84
+ cells = [c.strip() for c in row.strip("|").split("|")]
85
+ if len(cells) < 3:
86
+ continue
87
+ slug = cells[0].lower()
88
+ if slug in ("desk", "") or set(slug) <= set("-: "):
89
+ continue # the header row and the separator under it
90
+ desks.append(Desk(slug, cells[1], cells[2].strip().lower()))
91
+ return desks, None
92
+
93
+
94
+ def judge_desks(desks):
95
+ """Return a list of complaints about the table itself."""
96
+ out = []
97
+ if len(desks) < 2:
98
+ out.append("a fleet needs at least two desks. Found {}.".format(len(desks)))
99
+
100
+ seen = set()
101
+ for d in desks:
102
+ if not SLUG.match(d.slug):
103
+ out.append("'{}' is not a usable address. Lower case, digits and hyphens.".format(d.slug))
104
+ if d.slug in seen:
105
+ out.append("'{}' is listed twice. Two desks with one address share an inbox.".format(d.slug))
106
+ seen.add(d.slug)
107
+ if d.commits not in YES and d.commits not in NO:
108
+ out.append("'{}' answers '{}' to commits. Say yes or no.".format(d.slug, d.commits))
109
+
110
+ committers = [d.slug for d in desks if d.commits in YES]
111
+ if not committers:
112
+ out.append(
113
+ "nobody is allowed to commit. Decide who is, and write it in the table.\n"
114
+ " Every other mistake a fleet makes is recoverable. This one is the one\n"
115
+ " where two desks push over each other."
116
+ )
117
+ elif len(committers) > 1:
118
+ out.append(
119
+ "{} desks are allowed to commit: {}. Pick one.".format(
120
+ len(committers), ", ".join(committers))
121
+ )
122
+ return out
123
+
124
+
125
+ def bus_root(fleet_path):
126
+ return fleet_path.resolve().parent / ".claude" / "fleet"
127
+
128
+
129
+ def resolve(args):
130
+ """Load and validate the table, or explain why not. Returns (desks, root, error)."""
131
+ path = pathlib.Path(args.fleet)
132
+ desks, error = read_desks(path)
133
+ if error:
134
+ return None, None, error
135
+ complaints = judge_desks(desks)
136
+ if complaints:
137
+ return None, None, "\n".join(" " + c for c in complaints)
138
+ return desks, bus_root(path), None
139
+
140
+
141
+ def require(desks, slug, what):
142
+ known = [d.slug for d in desks]
143
+ if slug in known:
144
+ return None
145
+ return (
146
+ "'{}' is not a desk, so there is nowhere to {}.\n"
147
+ " Desks: {}\n"
148
+ " A message to an address nobody watches is worse than no message: the\n"
149
+ " sender believes it arrived.".format(slug, what, ", ".join(known))
150
+ )
151
+
152
+
153
+ def write_message(root, to, body):
154
+ """One file per message, created exclusively. Two senders cannot collide."""
155
+ box = root / to / "inbox"
156
+ box.mkdir(parents=True, exist_ok=True)
157
+ stamp = time.strftime("%Y%m%dT%H%M%S")
158
+ for n in range(1000):
159
+ target = box / "{}-{}-{}.md".format(stamp, os.getpid(), n)
160
+ try:
161
+ fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
162
+ except OSError as e:
163
+ if e.errno == errno.EEXIST:
164
+ continue
165
+ raise
166
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
167
+ fh.write(body)
168
+ return target
169
+ raise RuntimeError("could not create a message file in " + str(box))
170
+
171
+
172
+ def envelope(sender, to, subject, body):
173
+ return "From: {}\nTo: {}\nTime: {}\nSubject: {}\n\n{}\n".format(
174
+ sender, to, time.strftime("%Y-%m-%d %H:%M"), subject, body.rstrip("\n"))
175
+
176
+
177
+ # ------------------------------------------------------------------ commands
178
+
179
+
180
+ def cmd_desks(args):
181
+ path = pathlib.Path(args.fleet)
182
+ desks, error = read_desks(path)
183
+ if error:
184
+ print(error)
185
+ return 1
186
+ complaints = judge_desks(desks)
187
+
188
+ print("")
189
+ print(" {:<16} {:<40} {}".format("desk", "owns", "commits"))
190
+ for d in desks:
191
+ print(" {:<16} {:<40} {}".format(d.slug, d.owns[:40], d.commits or "-"))
192
+ print("")
193
+
194
+ if complaints:
195
+ for c in complaints:
196
+ print(" FAIL " + c)
197
+ print("")
198
+ return 1
199
+ print(" the table holds: {} desks, one of them commits.".format(len(desks)))
200
+ print("")
201
+ return 0
202
+
203
+
204
+ def cmd_send(args):
205
+ desks, root, error = resolve(args)
206
+ if error:
207
+ print(error)
208
+ return 1
209
+ for slug, what in ((args.to, "deliver"), (getattr(args, "from"), "send from")):
210
+ bad = require(desks, slug, what)
211
+ if bad:
212
+ print(bad)
213
+ return 1
214
+
215
+ body = args.body if args.body is not None else sys.stdin.read()
216
+ if not body.strip():
217
+ print("empty message. Say the thing, or do not send it.")
218
+ return 1
219
+
220
+ target = write_message(root, args.to, envelope(getattr(args, "from"), args.to, args.subject, body))
221
+ print("sent to {}: {}".format(args.to, target))
222
+ print("They see it when that session next opens and checks. Nothing here wakes it.")
223
+ return 0
224
+
225
+
226
+ def cmd_handoff(args):
227
+ desks, root, error = resolve(args)
228
+ if error:
229
+ print(error)
230
+ return 1
231
+ for slug, what in ((args.to, "deliver"), (getattr(args, "from"), "send from")):
232
+ bad = require(desks, slug, what)
233
+ if bad:
234
+ print(bad)
235
+ return 1
236
+
237
+ missing = [name for name in ("did", "verified", "next") if not getattr(args, name).strip()]
238
+ if missing:
239
+ print("a handoff without {} is not a handoff.".format(" or ".join(missing)))
240
+ print("The next desk cannot tell what is done from what was intended.")
241
+ print(" --did what changed")
242
+ print(" --verified the exact command you ran and what it said, or 'not verified'")
243
+ print(" --next what the next desk should pick up")
244
+ return 1
245
+
246
+ body = "Done:\n {}\n\nVerified:\n {}\n\nNext:\n {}\n".format(
247
+ args.did.strip(), args.verified.strip(), args.next.strip())
248
+ target = write_message(
249
+ root, args.to, envelope(getattr(args, "from"), args.to, "handoff: " + args.did.strip()[:60], body))
250
+ print("handed off to {}: {}".format(args.to, target))
251
+ return 0
252
+
253
+
254
+ def cmd_inbox(args):
255
+ desks, root, error = resolve(args)
256
+ if error:
257
+ print(error)
258
+ return 1
259
+ bad = require(desks, args.me, "read an inbox")
260
+ if bad:
261
+ print(bad)
262
+ return 1
263
+
264
+ box = root / args.me / "inbox"
265
+ files = sorted(box.glob("*.md")) if box.is_dir() else []
266
+ if not files:
267
+ print("no unread messages for {}.".format(args.me))
268
+ return 0
269
+ for f in files:
270
+ print("=" * 70)
271
+ print(f.name)
272
+ print("=" * 70)
273
+ print(f.read_text(encoding="utf-8", errors="replace"))
274
+ print("{} message(s). Act on them, then: python fleet.py archive --me {}".format(
275
+ len(files), args.me))
276
+ return 0
277
+
278
+
279
+ def cmd_archive(args):
280
+ desks, root, error = resolve(args)
281
+ if error:
282
+ print(error)
283
+ return 1
284
+ bad = require(desks, args.me, "archive an inbox")
285
+ if bad:
286
+ print(bad)
287
+ return 1
288
+
289
+ box = root / args.me / "inbox"
290
+ done = root / args.me / "read"
291
+ files = sorted(box.glob("*.md")) if box.is_dir() else []
292
+ if not files:
293
+ print("nothing to archive for {}.".format(args.me))
294
+ return 0
295
+ done.mkdir(parents=True, exist_ok=True)
296
+ for f in files:
297
+ f.replace(done / f.name)
298
+ print("archived {} message(s) for {}.".format(len(files), args.me))
299
+ return 0
300
+
301
+
302
+ def main():
303
+ ap = argparse.ArgumentParser(
304
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
305
+ )
306
+ ap.add_argument("--fleet", default="FLEET.md", help="the desk table (default: FLEET.md)")
307
+ sub = ap.add_subparsers(dest="cmd", required=True)
308
+
309
+ sub.add_parser("desks", help="print the desk table and check it")
310
+
311
+ s = sub.add_parser("send", help="send a message to a desk")
312
+ s.add_argument("--to", required=True)
313
+ s.add_argument("--from", required=True)
314
+ s.add_argument("--subject", required=True)
315
+ s.add_argument("--body", help="message body (default: read stdin)")
316
+
317
+ h = sub.add_parser("handoff", help="hand work to another desk, in the fixed shape")
318
+ h.add_argument("--to", required=True)
319
+ h.add_argument("--from", required=True)
320
+ h.add_argument("--did", default="")
321
+ h.add_argument("--verified", default="")
322
+ h.add_argument("--next", default="")
323
+
324
+ i = sub.add_parser("inbox", help="read your unread messages")
325
+ i.add_argument("--me", required=True)
326
+
327
+ a = sub.add_parser("archive", help="move what you read out of the inbox")
328
+ a.add_argument("--me", required=True)
329
+
330
+ args = ap.parse_args()
331
+ return {
332
+ "desks": cmd_desks,
333
+ "send": cmd_send,
334
+ "handoff": cmd_handoff,
335
+ "inbox": cmd_inbox,
336
+ "archive": cmd_archive,
337
+ }[args.cmd](args)
338
+
339
+
340
+ if __name__ == "__main__":
341
+ sys.exit(main())