sessionsteward 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.
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
sessionsteward/cli.py ADDED
@@ -0,0 +1,244 @@
1
+ """Session steward: versioned snapshots for agent working state.
2
+
3
+ Coding agents silently degrade: compaction drops constraints, and one
4
+ "remember this" can overwrite your 30-line CLAUDE.md with a single
5
+ bullet — no warning, no undo. sessionsteward git-versions your agent
6
+ config + memory files on every change. Instant undo, pre-compaction
7
+ snapshots, and a context-diet report.
8
+
9
+ Scope note: this is *session ops* (snapshots, undo, compaction guard).
10
+ Memory *storage* is OneMemory's job — the boundary stays clean.
11
+
12
+ sessionsteward init # set up the snapshots repo
13
+ sessionsteward watch --add CLAUDE.md # watch extra paths (also: ~/..., /abs/...)
14
+ sessionsteward snap -m "before refactor"
15
+ sessionsteward precompact # snapshot before compaction
16
+ sessionsteward log # list snapshots
17
+ sessionsteward diff [sha1] [sha2] # what changed (e.g. what compaction dropped)
18
+ sessionsteward undo # restore the previous snapshot
19
+ sessionsteward restore <sha> # restore a specific snapshot
20
+ sessionsteward diet # what's eating the context window
21
+ sessionsteward install-hooks [--write] # Claude Code hooks JSON
22
+ sessionsteward watch # daemon: auto-snapshot on change
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+ import time
32
+
33
+ from . import core as core_mod
34
+ from . import diet as diet_mod
35
+
36
+ VERSION = "0.1.0"
37
+
38
+ CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
39
+
40
+
41
+ def cmd_init(args) -> int:
42
+ repo = core_mod.init_repo()
43
+ print(f"snapshots repo: {repo}")
44
+ print("watching (defaults): " + ", ".join(core_mod.DEFAULT_WATCH))
45
+ print("add more: sessionsteward watch --add <path>")
46
+ return 0
47
+
48
+
49
+ def cmd_watch_add(args) -> int:
50
+ core_mod.init_repo()
51
+ cfg = core_mod.load_config()
52
+ for p in args.add:
53
+ if p not in cfg["watch"]:
54
+ cfg["watch"].append(p)
55
+ core_mod.save_config(cfg)
56
+ print("watching: " + ", ".join(cfg["watch"] or core_mod.DEFAULT_WATCH))
57
+ return 0
58
+
59
+
60
+ def cmd_snap(args) -> int:
61
+ sha = core_mod.snap(args.root, args.message or "manual snapshot")
62
+ print(f"snapshot {sha[:7]}")
63
+ return 0
64
+
65
+
66
+ def cmd_precompact(args) -> int:
67
+ sha = core_mod.snap(args.root, "pre-compaction snapshot")
68
+ print(f"pre-compaction snapshot {sha[:7]} — "
69
+ f"`sessionsteward diff {sha[:7]}` after, to see what was dropped")
70
+ return 0
71
+
72
+
73
+ def cmd_log(args) -> int:
74
+ if not core_mod.repo_ready():
75
+ print("no snapshots yet — run `sessionsteward init` first")
76
+ return 0
77
+ for s in core_mod.log(args.limit, project_root=args.root):
78
+ print(f"{s['sha'][:7]} {s['date'][:16]} {s['message']}")
79
+ return 0
80
+
81
+
82
+ def cmd_diff(args) -> int:
83
+ if not core_mod.repo_ready():
84
+ print("no snapshots yet")
85
+ return 2
86
+ snaps = core_mod.log(50, project_root=args.root)
87
+ sha1 = args.sha1 or (snaps[1]["sha"] if len(snaps) > 1 else None)
88
+ sha2 = args.sha2 or "HEAD"
89
+ if not sha1:
90
+ print("only one snapshot — nothing to diff")
91
+ return 0
92
+ out = core_mod.diff(sha1, sha2)
93
+ kept = [ln for ln in out.splitlines() if ln[:1] in "+-"
94
+ and not ln.startswith(("+++", "---"))]
95
+ sys.stdout.write("\n".join(kept[:200]) + ("\n" if kept else "(no changes)\n"))
96
+ return 0
97
+
98
+
99
+ def cmd_undo(args) -> int:
100
+ if not core_mod.repo_ready():
101
+ print("no snapshots yet")
102
+ return 2
103
+ snaps = core_mod.log(50, project_root=args.root)
104
+ if not snaps:
105
+ print("nothing to restore — no snapshots yet")
106
+ return 1
107
+ if core_mod.worktree_matches_snapshot(snaps[0]["sha"], args.root):
108
+ # Clean state: the current state is already snapshotted, so undo
109
+ # needs no pre-snap and redo is just `restore` of snaps[0].
110
+ if len(snaps) < 2:
111
+ print("only one snapshot — nothing to undo to")
112
+ return 0
113
+ target = snaps[min(args.steps, len(snaps) - 1)]["sha"]
114
+ else:
115
+ # Dirty state: snapshot it first ("pre-undo") so undo is
116
+ # reversible — redo is `restore` of the pre-undo snapshot.
117
+ core_mod.snap(args.root, "pre-undo")
118
+ snaps = core_mod.log(50, project_root=args.root)
119
+ target = snaps[min(args.steps, len(snaps) - 1)]["sha"]
120
+ try:
121
+ restored = core_mod.restore(target, project_root=args.root)
122
+ except core_mod.NothingToRestoreError as e:
123
+ print(e)
124
+ return 1
125
+ except core_mod.CrossProjectError as e:
126
+ print(f"error: {e}")
127
+ return 1
128
+ print(f"undone {args.steps} step(s) -> {target[:7]}")
129
+ for r in restored:
130
+ print(f" {r}")
131
+ return 0
132
+
133
+
134
+ def cmd_restore(args) -> int:
135
+ if not core_mod.repo_ready():
136
+ print("no snapshots yet")
137
+ return 2
138
+ # Validate before the pre-restore snap so a bad sha doesn't pollute log.
139
+ try:
140
+ core_mod.check_restore(args.sha, args.root)
141
+ except core_mod.NothingToRestoreError as e:
142
+ print(e)
143
+ return 1
144
+ except core_mod.CrossProjectError as e:
145
+ print(f"error: {e}")
146
+ return 1
147
+ # Redo support: if the current state isn't snapshotted yet, snapshot
148
+ # it first ("pre-restore") so the restore is reversible.
149
+ snaps = core_mod.log(50, project_root=args.root)
150
+ if snaps and not core_mod.worktree_matches_snapshot(snaps[0]["sha"],
151
+ args.root):
152
+ core_mod.snap(args.root, "pre-restore")
153
+ restored = core_mod.restore(args.sha, project_root=args.root)
154
+ print(f"restored {args.sha[:7]}")
155
+ for r in restored:
156
+ print(f" {r}")
157
+ return 0
158
+
159
+
160
+ def cmd_diet(args) -> int:
161
+ print(diet_mod.render(diet_mod.analyze(args.root)))
162
+ return 0
163
+
164
+
165
+ def cmd_install_hooks(args) -> int:
166
+ payload = core_mod.build_hooks_json()
167
+ print(json.dumps(payload, indent=2))
168
+ print()
169
+ print("Copy the JSON above into the \"hooks\" section of "
170
+ "~/.claude/settings.json, or re-run with --write to merge it "
171
+ "automatically (existing settings are preserved).")
172
+ print("Wiring: PreCompact -> `sessionsteward precompact`, "
173
+ "SessionStart -> `sessionsteward snap -m \"session start\"`.")
174
+ if args.write:
175
+ path = core_mod.write_hooks_file(CLAUDE_SETTINGS)
176
+ print(f"merged hooks into {path}")
177
+ return 0
178
+
179
+
180
+ def cmd_watch(args) -> int:
181
+ core_mod.init_repo()
182
+ print(f"watching for changes (every {args.interval}s, Ctrl-C to stop)…")
183
+ mtimes = core_mod.file_mtimes(core_mod.watched_paths(args.root))
184
+ try:
185
+ while True:
186
+ time.sleep(args.interval)
187
+ now = core_mod.file_mtimes(core_mod.watched_paths(args.root))
188
+ if now != mtimes:
189
+ sha = core_mod.snap(args.root, "auto: files changed")
190
+ print(f"change detected -> snapshot {sha[:7]}")
191
+ mtimes = now
192
+ except KeyboardInterrupt:
193
+ print("\nstopped.")
194
+ return 0
195
+
196
+
197
+ def build_parser() -> argparse.ArgumentParser:
198
+ p = argparse.ArgumentParser(prog="sessionsteward", description=__doc__)
199
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
200
+ p.add_argument("--root", default=os.getcwd(),
201
+ help="Project root for relative watch paths.")
202
+ sub = p.add_subparsers(dest="cmd", required=True)
203
+
204
+ sub.add_parser("init", help="Set up the snapshots repo.")
205
+ wa = sub.add_parser("watch", help="Watch daemon / manage watch list.")
206
+ wa.add_argument("--add", nargs="+", default=None,
207
+ help="Add paths to the watch list (then exit).")
208
+ wa.add_argument("--interval", type=float, default=5.0,
209
+ help="Poll seconds for the daemon.")
210
+ sn = sub.add_parser("snap", help="Snapshot now.")
211
+ sn.add_argument("-m", "--message", default=None)
212
+ sub.add_parser("precompact", help="Snapshot before compaction.")
213
+ lg = sub.add_parser("log", help="List snapshots.")
214
+ lg.add_argument("--limit", type=int, default=20)
215
+ df = sub.add_parser("diff", help="Diff two snapshots.")
216
+ df.add_argument("sha1", nargs="?")
217
+ df.add_argument("sha2", nargs="?", default="HEAD")
218
+ un = sub.add_parser("undo", help="Restore the previous snapshot.")
219
+ un.add_argument("--steps", type=int, default=1)
220
+ rs = sub.add_parser("restore", help="Restore a specific snapshot.")
221
+ rs.add_argument("sha")
222
+ sub.add_parser("diet", help="Context-diet report.")
223
+ ih = sub.add_parser("install-hooks",
224
+ help="Print (or --write) Claude Code hooks JSON.")
225
+ ih.add_argument("--write", action="store_true",
226
+ help="Merge the hooks into ~/.claude/settings.json.")
227
+ return p
228
+
229
+
230
+ def main(argv=None) -> int:
231
+ args = build_parser().parse_args(argv)
232
+ if args.cmd == "watch" and args.add:
233
+ return cmd_watch_add(args)
234
+ cmds = {
235
+ "init": cmd_init, "watch": cmd_watch, "snap": cmd_snap,
236
+ "precompact": cmd_precompact, "log": cmd_log, "diff": cmd_diff,
237
+ "undo": cmd_undo, "restore": cmd_restore, "diet": cmd_diet,
238
+ "install-hooks": cmd_install_hooks,
239
+ }
240
+ return cmds[args.cmd](args)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())
sessionsteward/core.py ADDED
@@ -0,0 +1,468 @@
1
+ """Snapshot backend: git-versions watched files. Every snap is a commit.
2
+
3
+ Layout inside the snapshots repo:
4
+ files/<rel> mirrored watched files
5
+ manifest.json {rel: abspath} for the latest snap
6
+
7
+ rel = path relative to project root, or __home/... for home files.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ import time
19
+
20
+ REPO_DIR = os.path.expanduser("~/.sessionsteward/snapshots")
21
+ CONFIG_PATH = os.path.expanduser("~/.sessionsteward/config.json")
22
+ TRASH_DIR = os.path.expanduser("~/.sessionsteward/trash")
23
+
24
+ DEFAULT_WATCH = ["CLAUDE.md", ".claude", "AGENTS.md"]
25
+
26
+ # Names skipped when walking watched directories (snap + watch daemon).
27
+ # Transcripts and logs balloon the snapshots repo; they are never
28
+ # snapshotted. `.git` is always skipped too.
29
+ IGNORE_PATTERNS = ["*.log", "*transcript*", "__pycache__", ".git",
30
+ "node_modules"]
31
+
32
+
33
+ class StewardError(Exception):
34
+ """Base for clean, user-facing sessionsteward errors."""
35
+
36
+
37
+ class NothingToRestoreError(StewardError):
38
+ """Raised when a snapshot has no file manifest (e.g. the init commit)."""
39
+
40
+
41
+ class CrossProjectError(StewardError):
42
+ """Raised when a snapshot belongs to a different project root."""
43
+
44
+
45
+ def _ignored_name(name: str) -> bool:
46
+ return any(fnmatch.fnmatch(name, pat) for pat in IGNORE_PATTERNS)
47
+
48
+
49
+ def _norm_root(root: str | None) -> str | None:
50
+ if root is None:
51
+ return None
52
+ return os.path.abspath(os.path.expanduser(root))
53
+
54
+
55
+ def _git(repo: str, *args: str) -> str:
56
+ p = subprocess.run(["git", "-C", repo, *args], capture_output=True,
57
+ text=True, timeout=60)
58
+ if p.returncode != 0:
59
+ raise RuntimeError(f"git {' '.join(args)} failed: "
60
+ f"{p.stderr.strip()[:200]}")
61
+ return p.stdout
62
+
63
+
64
+ def repo_ready() -> bool:
65
+ return os.path.isdir(os.path.join(REPO_DIR, ".git"))
66
+
67
+
68
+ def init_repo() -> str:
69
+ os.makedirs(REPO_DIR, exist_ok=True)
70
+ if not repo_ready():
71
+ _git(REPO_DIR, "init", "-qb", "main")
72
+ _git(REPO_DIR, "config", "user.email", "sessionsteward@local")
73
+ _git(REPO_DIR, "config", "user.name", "sessionsteward")
74
+ _git(REPO_DIR, "commit", "-q", "--allow-empty", "-m", "init")
75
+ if not os.path.exists(CONFIG_PATH):
76
+ save_config({"watch": []})
77
+ return REPO_DIR
78
+
79
+
80
+ def load_config() -> dict:
81
+ try:
82
+ with open(CONFIG_PATH) as f:
83
+ return json.load(f)
84
+ except (OSError, ValueError):
85
+ return {"watch": []}
86
+
87
+
88
+ def save_config(cfg: dict) -> None:
89
+ os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
90
+ with open(CONFIG_PATH, "w") as f:
91
+ json.dump(cfg, f, indent=2)
92
+
93
+
94
+ def watched_paths(project_root: str) -> list[str]:
95
+ """Absolute paths of watched files that currently exist."""
96
+ cfg = load_config()
97
+ rels = cfg.get("watch") or list(DEFAULT_WATCH)
98
+ out = []
99
+ for rel in rels:
100
+ if rel.startswith("~/"):
101
+ p = os.path.expanduser(rel)
102
+ elif os.path.isabs(rel):
103
+ p = rel
104
+ else:
105
+ p = os.path.join(project_root, rel)
106
+ if os.path.exists(p):
107
+ out.append(os.path.abspath(p))
108
+ return out
109
+
110
+
111
+ def _rel(abspath: str, project_root: str) -> str:
112
+ home = os.path.expanduser("~")
113
+ if abspath == home or abspath.startswith(home + os.sep):
114
+ return os.path.join("__home", os.path.relpath(abspath, home))
115
+ try:
116
+ return os.path.relpath(abspath, project_root)
117
+ except ValueError:
118
+ return abspath.lstrip(os.sep).replace(os.sep, "_")
119
+
120
+
121
+ def snap(project_root: str, message: str) -> str:
122
+ """Copy watched files into the repo and commit. Returns commit sha."""
123
+ if not repo_ready():
124
+ init_repo()
125
+ paths = watched_paths(project_root)
126
+ manifest = {}
127
+ files_dir = os.path.join(REPO_DIR, "files")
128
+ for ap in paths:
129
+ rel = _rel(ap, project_root)
130
+ manifest[rel] = ap
131
+ dest = os.path.join(files_dir, rel)
132
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
133
+ if os.path.isdir(ap):
134
+ if os.path.exists(dest):
135
+ shutil.rmtree(dest)
136
+ shutil.copytree(ap, dest,
137
+ ignore=shutil.ignore_patterns(*IGNORE_PATTERNS))
138
+ else:
139
+ shutil.copy2(ap, dest)
140
+ with open(os.path.join(REPO_DIR, "manifest.json"), "w") as f:
141
+ json.dump({"project_root": _norm_root(project_root), "files": manifest,
142
+ "at": int(time.time())}, f, indent=2)
143
+ _git(REPO_DIR, "add", "-A")
144
+ # skip empty commits (nothing changed)
145
+ status = _git(REPO_DIR, "status", "--porcelain")
146
+ if not status.strip():
147
+ return _git(REPO_DIR, "rev-parse", "HEAD").strip()
148
+ _git(REPO_DIR, "commit", "-q", "-m", message)
149
+ return _git(REPO_DIR, "rev-parse", "HEAD").strip()
150
+
151
+
152
+ def log(limit: int = 20, project_root: str | None = None) -> list[dict]:
153
+ """List snapshots, newest first.
154
+
155
+ When project_root is given, only snapshots whose manifest records
156
+ that project root are returned (manifest-less commits like the
157
+ initial `init` commit belong to no project and are excluded).
158
+ """
159
+ # When filtering by project we must scan past other projects'
160
+ # commits; fetch a wider window (documented bound for v1).
161
+ fetch = max(limit * 5, 100) if project_root else limit
162
+ out = _git(REPO_DIR, "log", f"-{fetch}",
163
+ "--pretty=format:%H|%ad|%s", "--date=iso")
164
+ want = _norm_root(project_root)
165
+ snaps = []
166
+ for line in out.splitlines():
167
+ sha, date, msg = line.split("|", 2)
168
+ entry = {"sha": sha, "date": date, "message": msg}
169
+ if want is not None:
170
+ m = _manifest_at(sha)
171
+ if not m or _norm_root(m.get("project_root")) != want:
172
+ continue
173
+ entry["project_root"] = m.get("project_root")
174
+ snaps.append(entry)
175
+ if len(snaps) >= limit:
176
+ break
177
+ return snaps
178
+
179
+
180
+ def _manifest_at(sha: str) -> dict:
181
+ try:
182
+ raw = _git(REPO_DIR, "show", f"{sha}:manifest.json")
183
+ except RuntimeError:
184
+ return {}
185
+ return json.loads(raw)
186
+
187
+
188
+ def _read_snapshot_file(sha: str, rel: str) -> tuple[bytes | None, bool]:
189
+ """(content, is_dir) for files/<rel> at sha."""
190
+ ls = _git(REPO_DIR, "ls-tree", sha, f"files/{rel}").strip()
191
+ if not ls:
192
+ return None, False
193
+ if ls.startswith("040000"):
194
+ return None, True
195
+ p = subprocess.run(["git", "-C", REPO_DIR, "show", f"{sha}:files/{rel}"],
196
+ capture_output=True, timeout=60)
197
+ return (p.stdout if p.returncode == 0 else None), False
198
+
199
+
200
+ def _move_to_trash(ap: str, trash_dir: str) -> str:
201
+ """Move a path into the trash dir; returns the trash destination."""
202
+ os.makedirs(trash_dir, exist_ok=True)
203
+ base = os.path.basename(ap) + f".{int(time.time())}"
204
+ dest = os.path.join(trash_dir, base)
205
+ n = 1
206
+ while os.path.lexists(dest):
207
+ n += 1
208
+ dest = os.path.join(trash_dir, f"{base}.{n}")
209
+ shutil.move(ap, dest)
210
+ return dest
211
+
212
+
213
+ def check_restore(sha: str, project_root: str | None = None) -> dict:
214
+ """Validate that sha is restorable for project_root.
215
+
216
+ Returns the snapshot's manifest. Raises NothingToRestoreError for a
217
+ manifest-less snapshot (e.g. the `init` commit) or an unknown sha,
218
+ and CrossProjectError when project_root is given and the snapshot
219
+ belongs to a different project.
220
+ """
221
+ manifest = _manifest_at(sha)
222
+ if not manifest:
223
+ p = subprocess.run(
224
+ ["git", "-C", REPO_DIR, "rev-parse", "--verify", "--quiet",
225
+ f"{sha}^{{commit}}"], capture_output=True, timeout=60)
226
+ if p.returncode == 0:
227
+ raise NothingToRestoreError(
228
+ f"nothing to restore — no snapshots yet "
229
+ f"(snapshot {sha[:7]} has no file manifest)")
230
+ raise NothingToRestoreError(f"no such snapshot: {sha[:7]}")
231
+ if project_root is not None:
232
+ want = _norm_root(project_root)
233
+ got = _norm_root(manifest.get("project_root"))
234
+ if got != want:
235
+ raise CrossProjectError(
236
+ f"refusing cross-project restore: snapshot {sha[:7]} "
237
+ f"belongs to project {manifest.get('project_root')!r}, "
238
+ f"not the current project {want!r}")
239
+ return manifest
240
+
241
+
242
+ def _dir_matches(sha: str, rel: str, ap: str) -> bool:
243
+ """True if the on-disk dir matches the snapshot's recorded tree."""
244
+ if not os.path.isdir(ap) or os.path.islink(ap):
245
+ return False
246
+ prefix = f"files/{rel}/"
247
+ snap_names = set()
248
+ for line in _git(REPO_DIR, "ls-tree", "-r", "--name-only",
249
+ sha, f"files/{rel}").splitlines():
250
+ if line.startswith(prefix):
251
+ snap_names.add(line[len(prefix):])
252
+ disk_names = set()
253
+ for root, dirs, files in os.walk(ap):
254
+ dirs[:] = [d for d in dirs if not _ignored_name(d)]
255
+ for fn in files:
256
+ if _ignored_name(fn):
257
+ continue
258
+ fp = os.path.join(root, fn)
259
+ name = os.path.relpath(fp, ap)
260
+ disk_names.add(name)
261
+ p = subprocess.run(
262
+ ["git", "-C", REPO_DIR, "show", f"{sha}:{prefix}{name}"],
263
+ capture_output=True, timeout=60)
264
+ if p.returncode != 0:
265
+ return False
266
+ try:
267
+ with open(fp, "rb") as f:
268
+ if f.read() != p.stdout:
269
+ return False
270
+ except OSError:
271
+ return False
272
+ return snap_names == disk_names
273
+
274
+
275
+ def worktree_matches_snapshot(sha: str, project_root: str) -> bool:
276
+ """True if the current watched files are byte-identical to the
277
+ snapshot's recorded state (redo is possible without a pre-snap)."""
278
+ manifest = _manifest_at(sha)
279
+ if not manifest:
280
+ return False
281
+ snap_files = manifest.get("files", {})
282
+ current = {os.path.abspath(p) for p in watched_paths(project_root)}
283
+ if set(snap_files.values()) != current:
284
+ return False
285
+ for rel, ap in snap_files.items():
286
+ data, is_dir = _read_snapshot_file(sha, rel)
287
+ if is_dir:
288
+ if not _dir_matches(sha, rel, ap):
289
+ return False
290
+ elif data is None:
291
+ return False
292
+ else:
293
+ try:
294
+ with open(ap, "rb") as f:
295
+ if f.read() != data:
296
+ return False
297
+ except OSError:
298
+ return False
299
+ return True
300
+
301
+
302
+ def _latest_manifest_for_project(project_root: str | None) -> dict:
303
+ """Manifest of the newest snapshot, scoped to project_root.
304
+
305
+ The snapshots repo is shared between projects, so global HEAD may
306
+ belong to a different project. The trash sweep in restore() must
307
+ compare extra files against the current project's latest state,
308
+ never another project's — otherwise undo in project A would yank
309
+ project B's watched files into trash. Returns {} when no snapshot
310
+ exists for the project; when project_root is None (unscoped API
311
+ use) falls back to the global HEAD manifest (legacy behavior).
312
+ """
313
+ if project_root is None:
314
+ return _manifest_at("HEAD")
315
+ snaps = log(1, project_root=project_root)
316
+ if not snaps:
317
+ return {}
318
+ return _manifest_at(snaps[0]["sha"])
319
+
320
+
321
+ def restore(sha: str, trash_dir: str | None = None,
322
+ project_root: str | None = None) -> list[str]:
323
+ """Restore watched files from a snapshot. Files that exist now but
324
+ didn't in the snapshot are moved to trash instead of deleted —
325
+ directories included (never `rmtree`'d).
326
+
327
+ Raises NothingToRestoreError for a manifest-less snapshot (e.g. the
328
+ `init` commit) and CrossProjectError when project_root is given and
329
+ the snapshot belongs to a different project.
330
+ """
331
+ manifest = check_restore(sha, project_root)
332
+ restored = []
333
+ # Scope the extra-file sweep to the current project: global HEAD may
334
+ # belong to another project, and sweeping against it would trash
335
+ # that project's watched files (cross-project clobbering).
336
+ latest = _latest_manifest_for_project(project_root)
337
+ trash_dir = trash_dir or TRASH_DIR
338
+ for rel, ap in latest.get("files", {}).items():
339
+ if rel not in manifest["files"] and os.path.lexists(ap):
340
+ dest = _move_to_trash(ap, trash_dir)
341
+ restored.append(f"moved {ap} -> {dest}")
342
+ for rel, ap in manifest["files"].items():
343
+ data, is_dir = _read_snapshot_file(sha, rel)
344
+ if is_dir:
345
+ tmp = tempfile.mkdtemp(prefix="steward-restore-")
346
+ try:
347
+ arch = subprocess.run(
348
+ ["git", "-C", REPO_DIR, "archive", sha, f"files/{rel}"],
349
+ capture_output=True, timeout=60)
350
+ if arch.returncode != 0:
351
+ continue
352
+ subprocess.run(["tar", "-x", "-C", tmp], input=arch.stdout,
353
+ capture_output=True, timeout=60)
354
+ src = os.path.join(tmp, "files", rel)
355
+ if os.path.lexists(ap):
356
+ # Never delete: move the existing path (dir, file or
357
+ # link) to trash before restoring the snapshot copy.
358
+ dest = _move_to_trash(ap, trash_dir)
359
+ restored.append(f"moved {ap} -> {dest}")
360
+ shutil.copytree(src, ap)
361
+ restored.append(ap)
362
+ finally:
363
+ shutil.rmtree(tmp, ignore_errors=True)
364
+ elif data is not None:
365
+ os.makedirs(os.path.dirname(ap), exist_ok=True)
366
+ with open(ap, "wb") as f:
367
+ f.write(data)
368
+ restored.append(ap)
369
+ return restored
370
+
371
+
372
+ def diff(sha1: str, sha2: str = "HEAD") -> str:
373
+ return _git(REPO_DIR, "diff", f"{sha1}..{sha2}", "--", "files/")
374
+
375
+
376
+ def file_mtimes(paths: list[str]) -> dict[str, float]:
377
+ mtimes = {}
378
+ for ap in paths:
379
+ if os.path.isdir(ap):
380
+ for root, dirs, files in os.walk(ap):
381
+ dirs[:] = [d for d in dirs if not _ignored_name(d)]
382
+ for fn in files:
383
+ if _ignored_name(fn):
384
+ continue
385
+ fp = os.path.join(root, fn)
386
+ try:
387
+ mtimes[fp] = os.path.getmtime(fp)
388
+ except OSError:
389
+ pass
390
+ elif os.path.exists(ap):
391
+ mtimes[ap] = os.path.getmtime(ap)
392
+ return mtimes
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Claude Code hook wiring (`install-hooks`)
397
+ # ---------------------------------------------------------------------------
398
+
399
+ HOOK_PRECOMPACT_CMD = "sessionsteward precompact"
400
+ HOOK_SESSIONSTART_CMD = 'sessionsteward snap -m "session start"'
401
+
402
+
403
+ def build_hooks_json() -> dict:
404
+ """The hooks payload for Claude Code's settings.json `hooks` section."""
405
+ return {
406
+ "hooks": {
407
+ "PreCompact": [
408
+ {"matcher": "",
409
+ "hooks": [{"type": "command",
410
+ "command": HOOK_PRECOMPACT_CMD}]}
411
+ ],
412
+ "SessionStart": [
413
+ {"matcher": "",
414
+ "hooks": [{"type": "command",
415
+ "command": HOOK_SESSIONSTART_CMD}]}
416
+ ],
417
+ }
418
+ }
419
+
420
+
421
+ def _hook_commands(entries: list) -> list[str]:
422
+ """Extract command strings from hook entries, defensively."""
423
+ cmds = []
424
+ for e in entries:
425
+ if not isinstance(e, dict):
426
+ continue
427
+ for h in e.get("hooks", []) or []:
428
+ if isinstance(h, dict) and "command" in h:
429
+ cmds.append(h["command"])
430
+ return cmds
431
+
432
+
433
+ def merge_hooks(settings: dict) -> dict:
434
+ """Merge our hooks into a settings dict without clobbering anything.
435
+
436
+ Existing keys, events and hook entries are preserved; our entries are
437
+ appended only if no existing entry already runs the same command
438
+ (re-running is idempotent).
439
+ """
440
+ ours = build_hooks_json()["hooks"]
441
+ hooks = settings.setdefault("hooks", {})
442
+ for event, entries in ours.items():
443
+ existing = hooks.setdefault(event, [])
444
+ have = set(_hook_commands(existing))
445
+ for entry in entries:
446
+ cmd = entry["hooks"][0]["command"]
447
+ if cmd in have:
448
+ continue
449
+ existing.append(entry)
450
+ have.add(cmd)
451
+ return settings
452
+
453
+
454
+ def write_hooks_file(settings_path: str) -> str:
455
+ """Merge hooks into the settings file at settings_path. Returns it."""
456
+ try:
457
+ with open(settings_path) as f:
458
+ settings = json.load(f)
459
+ except (OSError, ValueError):
460
+ settings = {}
461
+ if not isinstance(settings, dict):
462
+ settings = {}
463
+ merge_hooks(settings)
464
+ os.makedirs(os.path.dirname(settings_path) or ".", exist_ok=True)
465
+ with open(settings_path, "w") as f:
466
+ json.dump(settings, f, indent=2)
467
+ f.write("\n")
468
+ return settings_path
sessionsteward/diet.py ADDED
@@ -0,0 +1,86 @@
1
+ """Context diet: what's eating the agent's context window.
2
+
3
+ Reports per-file sizes and finds duplicated content across watched
4
+ files — the usual bloat: the same instructions pasted into CLAUDE.md,
5
+ .claude/, and the project README.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections import Counter
12
+
13
+ from . import core as core_mod
14
+
15
+ MIN_LINE_LEN = 40 # ignore short lines when hunting duplicates
16
+
17
+
18
+ def _read_text(path: str) -> str | None:
19
+ try:
20
+ with open(path, encoding="utf-8", errors="replace") as f:
21
+ return f.read()
22
+ except OSError:
23
+ return None
24
+
25
+
26
+ def _all_files(paths: list[str]) -> list[str]:
27
+ out = []
28
+ for ap in paths:
29
+ if os.path.isdir(ap):
30
+ for root, dirs, files in os.walk(ap):
31
+ dirs[:] = [d for d in dirs if d != ".git"]
32
+ out.extend(os.path.join(root, f) for f in files)
33
+ elif os.path.isfile(ap):
34
+ out.append(ap)
35
+ return out
36
+
37
+
38
+ def analyze(project_root: str, top_dupes: int = 10) -> dict:
39
+ paths = core_mod.watched_paths(project_root)
40
+ files = _all_files(paths)
41
+ per_file = []
42
+ line_owners: dict[str, set[str]] = {}
43
+ for fp in files:
44
+ text = _read_text(fp)
45
+ if text is None:
46
+ continue
47
+ lines = text.splitlines()
48
+ per_file.append({"path": fp, "chars": len(text), "lines": len(lines)})
49
+ seen = set()
50
+ for ln in lines:
51
+ s = ln.strip()
52
+ if len(s) >= MIN_LINE_LEN and s not in seen:
53
+ seen.add(s)
54
+ line_owners.setdefault(s, set()).add(fp)
55
+ dupes = [{"line": ln[:100], "files": sorted(owners), "count": len(owners)}
56
+ for ln, owners in line_owners.items() if len(owners) > 1]
57
+ dupes.sort(key=lambda d: (-d["count"], -len(d["line"])))
58
+ per_file.sort(key=lambda f: -f["chars"])
59
+ total = sum(f["chars"] for f in per_file)
60
+ return {"files": per_file, "total_chars": total,
61
+ "duplicates": dupes[:top_dupes],
62
+ "n_duplicates": len(dupes)}
63
+
64
+
65
+ def render(report: dict) -> str:
66
+ lines = ["\n# Context diet\n",
67
+ f"Watched files: {len(report['files'])}, "
68
+ f"total ~{report['total_chars']:,} chars "
69
+ f"(~{report['total_chars'] // 4:,} tokens)\n",
70
+ "## Biggest files",
71
+ "| file | chars | lines |",
72
+ "|---|---|---|"]
73
+ for f in report["files"][:10]:
74
+ lines.append(f"| `{f['path']}` | {f['chars']:,} | {f['lines']} |")
75
+ lines.append("")
76
+ if report["duplicates"]:
77
+ lines.append(f"## Duplicated content "
78
+ f"({report['n_duplicates']} lines in 2+ files)")
79
+ for d in report["duplicates"]:
80
+ lines.append(f"- _{d['line']}_ …")
81
+ for fp in d["files"]:
82
+ lines.append(f" - `{fp}`")
83
+ else:
84
+ lines.append("No significant duplication across watched files. 🎉")
85
+ lines.append("")
86
+ return "\n".join(lines)
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: sessionsteward
3
+ Version: 0.1.0
4
+ Summary: Versioned snapshots for agent working state: instant undo for CLAUDE.md and friends.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/sessionsteward
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/sessionsteward/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # Session Steward (`sessionsteward`)
15
+
16
+ Versioned snapshots for agent working state. Coding agents silently
17
+ degrade: compaction drops constraints, and one "remember this" can
18
+ overwrite your 30-line CLAUDE.md with a single bullet — no warning, no
19
+ undo. sessionsteward git-versions your agent config + memory files on
20
+ every change: instant undo, pre-compaction snapshots, and a context-diet
21
+ report showing what's eating the window.
22
+
23
+ **Scope:** session *ops* (snapshots, undo, compaction guard) — not memory
24
+ *storage*. That boundary stays clean.
25
+
26
+ ## Install
27
+
28
+ Zero dependencies — Python 3.10+ stdlib only (uses your system's git).
29
+
30
+ ```bash
31
+ git clone <repo> && cd sessionsteward
32
+ # no-install alias (run from the repo root):
33
+ alias sessionsteward="python3 -m sessionsteward"
34
+ ```
35
+
36
+ Or install it properly (console script `sessionsteward`):
37
+
38
+ ```bash
39
+ pip install .
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ sessionsteward init # set up the snapshots repo
46
+ sessionsteward watch --add NOTES.md ~/x.md # watch extra paths
47
+
48
+ sessionsteward snap -m "before refactor" # snapshot now
49
+ sessionsteward precompact # snapshot before compaction
50
+ sessionsteward watch # daemon: auto-snapshot on change
51
+
52
+ sessionsteward log # list snapshots (this project only)
53
+ sessionsteward diff # what changed (e.g. what compaction dropped)
54
+ sessionsteward undo # restore the previous snapshot
55
+ sessionsteward restore <sha> # restore a specific snapshot
56
+ sessionsteward diet # what's eating the context window
57
+ sessionsteward install-hooks # print Claude Code hooks JSON
58
+ sessionsteward install-hooks --write # merge it into ~/.claude/settings.json
59
+ ```
60
+
61
+ The horror-story scenario, fixed:
62
+
63
+ ```bash
64
+ $ sessionsteward snap -m "before letting the agent edit memory"
65
+ $ # agent overwrites your 30-line CLAUDE.md with one bullet
66
+ $ sessionsteward undo
67
+ undone 1 step(s) -> a3f9c21
68
+ /home/you/proj/CLAUDE.md
69
+ ```
70
+
71
+ Safety rules: empty snaps are skipped (no noise commits); `undo`/`restore`
72
+ never delete — files that didn't exist in the target snapshot are moved to
73
+ `~/.sessionsteward/trash/` instead, **directories included**.
74
+ `log`/`diff`/`undo`/`restore` are scoped to the current project (`--root`,
75
+ default: cwd): snapshots record their project root, and a `restore` of a
76
+ snapshot from another project is refused rather than clobbering your files.
77
+ Undoing (or restoring) with unsaved changes first snapshots the current
78
+ state as `pre-undo`/`pre-restore`, so every undo is reversible — redo is
79
+ `restore` of that snapshot.
80
+
81
+ Ignored on every snap and by the watch daemon: `*.log`, `*transcript*`,
82
+ `__pycache__`, `.git`, `node_modules` (transcripts would otherwise balloon
83
+ the snapshots repo).
84
+
85
+ ## Claude Code hooks
86
+
87
+ ```bash
88
+ sessionsteward install-hooks # print the hooks JSON + where it goes
89
+ sessionsteward install-hooks --write # merge into ~/.claude/settings.json
90
+ ```
91
+
92
+ This wires **PreCompact** → `sessionsteward precompact` and **SessionStart**
93
+ → `sessionsteward snap -m "session start"`. `--write` merges into the
94
+ existing settings file without clobbering your other hooks (re-running is
95
+ a no-op).
96
+
97
+ ## Context diet
98
+
99
+ ```
100
+ # Context diet
101
+
102
+ Watched files: 3, total ~12,400 chars (~3,100 tokens)
103
+
104
+ ## Biggest files
105
+ | file | chars | lines |
106
+ ...
107
+ ## Duplicated content (4 lines in 2+ files)
108
+ - _Be helpful and concise in all responses_ …
109
+ - `/proj/CLAUDE.md`
110
+ - `/proj/.claude/settings.json`
111
+ ```
112
+
113
+ ## Suggested hook wiring
114
+
115
+ - **Pre-compaction:** run `sessionsteward precompact` (or add it to your
116
+ agent's pre-compact hook) — then `sessionsteward diff` afterwards to see
117
+ exactly what the compaction dropped.
118
+ - **Session start:** `sessionsteward snap -m "session start"`.
119
+ - **Memory writes:** point the steward at the same files your agent's
120
+ memory hook writes.
121
+
122
+ ## Tests
123
+
124
+ ```bash
125
+ python3 -m unittest discover -s tests # 24 tests, temp dirs only
126
+ ```
127
+
128
+ ## License
129
+
130
+ TBD
@@ -0,0 +1,11 @@
1
+ sessionsteward/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ sessionsteward/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ sessionsteward/cli.py,sha256=--QdBFFarCGVYYhX_OFPtNTgm3K0kF4xC45qME2490Y,9158
4
+ sessionsteward/core.py,sha256=r8cep6Sh2qC54ax47Au9YV53HjqHKAi9etE1jDxfMAI,16917
5
+ sessionsteward/diet.py,sha256=O6UTsnlxXqoK_TGtrc_I6ODJVnCMBuX3pb5VeI4VMQc,2954
6
+ sessionsteward-0.1.0.dist-info/licenses/LICENSE,sha256=f-qFLkEfvZidBgKv2YINOOKS7Trk_gj1c6Cy718D8F0,1079
7
+ sessionsteward-0.1.0.dist-info/METADATA,sha256=9bRlF8LQcG_oDtejtwnkk3IVRIp8kveR8GuEeYUL46A,4463
8
+ sessionsteward-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ sessionsteward-0.1.0.dist-info/entry_points.txt,sha256=Ou0wQQdM0Hci2C4bC672mdMFu3oftX7uff5y7L3opCc,59
10
+ sessionsteward-0.1.0.dist-info/top_level.txt,sha256=nwNSjQxhH7HmIuDQgWqPLgxnN4xVoPOKmWfDyuJxRDk,15
11
+ sessionsteward-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sessionsteward = sessionsteward.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sessionsteward