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.
- sessionsteward/__init__.py +0 -0
- sessionsteward/__main__.py +4 -0
- sessionsteward/cli.py +244 -0
- sessionsteward/core.py +468 -0
- sessionsteward/diet.py +86 -0
- sessionsteward-0.1.0.dist-info/METADATA +130 -0
- sessionsteward-0.1.0.dist-info/RECORD +11 -0
- sessionsteward-0.1.0.dist-info/WHEEL +5 -0
- sessionsteward-0.1.0.dist-info/entry_points.txt +2 -0
- sessionsteward-0.1.0.dist-info/licenses/LICENSE +21 -0
- sessionsteward-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
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,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
|