getlogbook 0.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Veertej Sehdave
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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: getlogbook
3
+ Version: 0.0.1
4
+ Summary: Commit-anchored shared memory for AI-assisted repos (stdlib only).
5
+ Author: Veertej Sehdave
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vssehdave/Logbook
8
+ Project-URL: Issues, https://github.com/vssehdave/Logbook/issues
9
+ Keywords: git,ai-agents,dbt,memory,developer-tools
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # Logbook
26
+
27
+ Commit-anchored shared memory for AI-assisted repos. It records **why** code and warehouse models changed
28
+ (what was wrong, how wrong, the fix) next to the git history, so a teammate or an AI agent can pick up where the
29
+ last person left off. Works for any repo; dbt-style repos get layer awareness (`stg_`, `int_`, `fct_`, `dim_`).
30
+
31
+ - Python 3.10+, **standard library only**, git 2.31+. Windows, macOS, Linux. No server to host, no model calls (`backend none`).
32
+ - Two logs: a **private** one under `.git/logbook/` (never tracked), and a **shared** one on git refs
33
+ (`refs/logbook/log/<user>/<device>`, one writer per ref, so no merge conflicts). Sharing is opt-in per entry.
34
+ - A live local viewer (`logbook serve`) shows the shared tree with your private entries overlaid.
35
+
36
+ **Status: alpha (0.0.1).** The engine, git-ref storage, sharing and the viewer work and are tested on Linux, macOS and Windows.
37
+ Not built yet: nothing prompts an agent to write a note, so real reasons exist only when someone runs `logbook note`;
38
+ notes written on a branch before its first commit are not followed through a merge; there are no summaries above commits;
39
+ setup is three separate commands; Claude Code is the only host. Entries marked `inferred` are copied from the commit message
40
+ or a truncated transcript. They carry no reason and are never injected into an agent's prompt.
41
+
42
+ ## Try it (no credentials, about 30 seconds)
43
+
44
+ pip install getlogbook # from a clone: pip install . (or: PYTHONPATH=src python -m getlogbook ...)
45
+ # the command is `logbook`; the package name is getlogbook because `logbook` on PyPI is taken
46
+ logbook demo # Alice, Bob and a hostile Mallory on a scratch repo; every claim is checked
47
+ logbook demo --serve # same, then open the viewer on Bob's clone
48
+
49
+ ## Daily use
50
+
51
+ logbook init # once per clone (git config user.email must be set)
52
+ logbook install-hook # git floor: copies each of your commits into an `inferred` entry (no reason)
53
+ logbook install --agent claude-code --write # merges hooks into .claude/settings.local.json (dry run without --write)
54
+ logbook note --kind finding --topic "late payments" --summary "revenue dip" --why "export stalled" --wrong "1 day under"
55
+ logbook note --kind change --before-commit --summary "..." --why "..." --wrong "..." --fix "..." # then commit as usual
56
+ logbook publish <id> # preview + redaction, you confirm; evidence is opt-in
57
+ logbook sync # fetch teammates' entries, push yours
58
+ logbook query "refunds" | explain fct_revenue | path <rev> <rev> # exit 0 found, 3 ambiguous, 4 no match
59
+ logbook serve --open
60
+ logbook doctor # setup + what is NOT captured
61
+
62
+ New teammate: clone, then `logbook join`. A plain `git clone`/`git fetch` does not download logbook refs.
63
+ CI: `git fetch --depth=1 origin '+refs/logbook/log/*:refs/logbook-remote/origin/log/*'` (opt in; off by default).
64
+
65
+ ## How an agent consults it first
66
+
67
+ Hooks (Claude Code is the only adapter shipped): `SessionStart` and `UserPromptSubmit` inject a digest of at most ~1,500
68
+ characters, only when the prompt names a known model or path, only from people who have commits on this history (or whom you
69
+ ran `logbook trust` on), only entries that say *why* or
70
+ *how wrong*, finding first, each labelled with author, provenance, age, commits behind HEAD and "verify first". Each note
71
+ is shown once per session. Agents without hooks: `logbook snippet` prints text for CLAUDE.md / AGENTS.md. Injection
72
+ guarantees the notes are in context, not that the model uses them.
73
+
74
+ ## Safety defaults
75
+
76
+ - Entries from other writers are **untrusted data**: author comes from the ref path, never from the file; text is
77
+ sanitised (links, code fences, instruction-like lines, angle brackets removed) before it reaches an agent, in `inject`
78
+ and in CLI output; the viewer renders only with `textContent` under a strict CSP.
79
+ - Every entry is recorded, searchable and visible in the viewer. Trust only decides what is *automatically injected into an
80
+ agent's prompt*: your own entries, entries from anyone with a commit on this history (no setup), and writers you ran
81
+ `logbook trust <user-id>` on. A writer with push access who never committed is searchable but not injected.
82
+ Strict mode: `"inject_from": "self"` in `.logbook/policy.json`, or `trust_committers: false` in your config.
83
+ Limit: the id in a ref path is self-asserted, so anyone who can push refs could write under a committer's id; only
84
+ host-side ref protection prevents that.
85
+ - Publishing: allowlisted fields only, secrets/emails/connection strings masked, pasted table rows refused, exact-bytes preview
86
+ with hash check, a local audit log. Redaction is a lint, not a guarantee. Published text can persist in forks and mirrors.
87
+ - `logbook purge <id> --push` rewrites your own ref without the entry and leaves an id-only tombstone. Rotate any leaked secret.
88
+ - Optional `.logbook/policy.json` (tracked): `publish`, `inject_from` (`self`|`trusted`), `attribution` (`none`|`pseudonym`|`named`
89
+ ceiling), `max_entry_bytes`. The client only ever gets stricter from it. Default attribution is a pseudonymous id.
90
+
91
+ See `docs/DESIGN.md` for decisions, the failure-mode table, and what is not built.
@@ -0,0 +1,67 @@
1
+ # Logbook
2
+
3
+ Commit-anchored shared memory for AI-assisted repos. It records **why** code and warehouse models changed
4
+ (what was wrong, how wrong, the fix) next to the git history, so a teammate or an AI agent can pick up where the
5
+ last person left off. Works for any repo; dbt-style repos get layer awareness (`stg_`, `int_`, `fct_`, `dim_`).
6
+
7
+ - Python 3.10+, **standard library only**, git 2.31+. Windows, macOS, Linux. No server to host, no model calls (`backend none`).
8
+ - Two logs: a **private** one under `.git/logbook/` (never tracked), and a **shared** one on git refs
9
+ (`refs/logbook/log/<user>/<device>`, one writer per ref, so no merge conflicts). Sharing is opt-in per entry.
10
+ - A live local viewer (`logbook serve`) shows the shared tree with your private entries overlaid.
11
+
12
+ **Status: alpha (0.0.1).** The engine, git-ref storage, sharing and the viewer work and are tested on Linux, macOS and Windows.
13
+ Not built yet: nothing prompts an agent to write a note, so real reasons exist only when someone runs `logbook note`;
14
+ notes written on a branch before its first commit are not followed through a merge; there are no summaries above commits;
15
+ setup is three separate commands; Claude Code is the only host. Entries marked `inferred` are copied from the commit message
16
+ or a truncated transcript. They carry no reason and are never injected into an agent's prompt.
17
+
18
+ ## Try it (no credentials, about 30 seconds)
19
+
20
+ pip install getlogbook # from a clone: pip install . (or: PYTHONPATH=src python -m getlogbook ...)
21
+ # the command is `logbook`; the package name is getlogbook because `logbook` on PyPI is taken
22
+ logbook demo # Alice, Bob and a hostile Mallory on a scratch repo; every claim is checked
23
+ logbook demo --serve # same, then open the viewer on Bob's clone
24
+
25
+ ## Daily use
26
+
27
+ logbook init # once per clone (git config user.email must be set)
28
+ logbook install-hook # git floor: copies each of your commits into an `inferred` entry (no reason)
29
+ logbook install --agent claude-code --write # merges hooks into .claude/settings.local.json (dry run without --write)
30
+ logbook note --kind finding --topic "late payments" --summary "revenue dip" --why "export stalled" --wrong "1 day under"
31
+ logbook note --kind change --before-commit --summary "..." --why "..." --wrong "..." --fix "..." # then commit as usual
32
+ logbook publish <id> # preview + redaction, you confirm; evidence is opt-in
33
+ logbook sync # fetch teammates' entries, push yours
34
+ logbook query "refunds" | explain fct_revenue | path <rev> <rev> # exit 0 found, 3 ambiguous, 4 no match
35
+ logbook serve --open
36
+ logbook doctor # setup + what is NOT captured
37
+
38
+ New teammate: clone, then `logbook join`. A plain `git clone`/`git fetch` does not download logbook refs.
39
+ CI: `git fetch --depth=1 origin '+refs/logbook/log/*:refs/logbook-remote/origin/log/*'` (opt in; off by default).
40
+
41
+ ## How an agent consults it first
42
+
43
+ Hooks (Claude Code is the only adapter shipped): `SessionStart` and `UserPromptSubmit` inject a digest of at most ~1,500
44
+ characters, only when the prompt names a known model or path, only from people who have commits on this history (or whom you
45
+ ran `logbook trust` on), only entries that say *why* or
46
+ *how wrong*, finding first, each labelled with author, provenance, age, commits behind HEAD and "verify first". Each note
47
+ is shown once per session. Agents without hooks: `logbook snippet` prints text for CLAUDE.md / AGENTS.md. Injection
48
+ guarantees the notes are in context, not that the model uses them.
49
+
50
+ ## Safety defaults
51
+
52
+ - Entries from other writers are **untrusted data**: author comes from the ref path, never from the file; text is
53
+ sanitised (links, code fences, instruction-like lines, angle brackets removed) before it reaches an agent, in `inject`
54
+ and in CLI output; the viewer renders only with `textContent` under a strict CSP.
55
+ - Every entry is recorded, searchable and visible in the viewer. Trust only decides what is *automatically injected into an
56
+ agent's prompt*: your own entries, entries from anyone with a commit on this history (no setup), and writers you ran
57
+ `logbook trust <user-id>` on. A writer with push access who never committed is searchable but not injected.
58
+ Strict mode: `"inject_from": "self"` in `.logbook/policy.json`, or `trust_committers: false` in your config.
59
+ Limit: the id in a ref path is self-asserted, so anyone who can push refs could write under a committer's id; only
60
+ host-side ref protection prevents that.
61
+ - Publishing: allowlisted fields only, secrets/emails/connection strings masked, pasted table rows refused, exact-bytes preview
62
+ with hash check, a local audit log. Redaction is a lint, not a guarantee. Published text can persist in forks and mirrors.
63
+ - `logbook purge <id> --push` rewrites your own ref without the entry and leaves an id-only tombstone. Rotate any leaked secret.
64
+ - Optional `.logbook/policy.json` (tracked): `publish`, `inject_from` (`self`|`trusted`), `attribution` (`none`|`pseudonym`|`named`
65
+ ceiling), `max_entry_bytes`. The client only ever gets stricter from it. Default attribution is a pseudonymous id.
66
+
67
+ See `docs/DESIGN.md` for decisions, the failure-mode table, and what is not built.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "getlogbook"
7
+ version = "0.0.1"
8
+ description = "Commit-anchored shared memory for AI-assisted repos (stdlib only)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Veertej Sehdave" }]
14
+ keywords = ["git", "ai-agents", "dbt", "memory", "developer-tools"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Version Control :: Git",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/vssehdave/Logbook"
31
+ Issues = "https://github.com/vssehdave/Logbook/issues"
32
+
33
+ [project.scripts]
34
+ logbook = "getlogbook.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ getlogbook = ["viewer.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """logbook: commit-anchored shared memory for AI-assisted repos."""
2
+ __version__ = "0.0.1"
@@ -0,0 +1,11 @@
1
+ import sys
2
+
3
+ if len(sys.argv) > 1 and sys.argv[1] in ("capture", "post-commit"):
4
+ # hook fast path: no argparse, no CLI imports (saves ~100 ms per invocation)
5
+ from .hookmain import run
6
+
7
+ raise SystemExit(run(sys.argv[1:]))
8
+
9
+ from .cli import main
10
+
11
+ raise SystemExit(main())
@@ -0,0 +1,372 @@
1
+ """Capture: hook entry point, spool, reconcile, git post-commit floor, hook installers.
2
+
3
+ Design rules (from the review panels):
4
+ * A hook only writes a small spool file and (maybe) spawns a detached worker. No model, no network, < 100 ms.
5
+ * A hook never fails the agent: everything is caught, logged to errors.log, exit code 0.
6
+ * `reconcile()` is the one sweep: spool -> transcript slices -> drafts; crash recovery; commits with no entry.
7
+ * Drafts built without a model are provenance=inferred and are never injected (no 'why').
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from . import git as g
20
+ from .entry import BODY_FIELDS, Entry, from_json, new_id
21
+ from .store import Repo, atomic_write
22
+
23
+ MARK_BEGIN, MARK_END = "# >>> logbook >>>", "# <<< logbook <<<"
24
+ CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd"]
25
+
26
+
27
+ def now():
28
+ return time.strftime("%Y-%m-%dT%H:%M:%S")
29
+
30
+
31
+ # ---- entries made by people/agents ---------------------------------------------------------------------------
32
+ def make_entry(repo, kind, topic, body, paths=None, provenance="agent-written", via="cli", pending=False, supersedes=()):
33
+ from . import tree
34
+ body = {k: str(v)[:2000] for k, v in body.items() if k in BODY_FIELDS and v}
35
+ e = Entry(id=new_id(), kind=kind, topic=topic[:200], body=body, provenance=provenance, captured_via=via,
36
+ supersedes=list(supersedes), created=now(),
37
+ display=repo.cfg().get("display", "") if repo.attribution() == "named" else "")
38
+ if pending: # change entries are written before the commit exists; post-commit attaches the sha
39
+ e.anchor = {"paths": list(paths or [])}
40
+ atomic_write(repo.cdir / "pending" / f"{e.id}.json", e.to_json())
41
+ else:
42
+ e.anchor = tree.anchor_for(repo, paths=paths)
43
+ repo.add_private(e)
44
+ return e
45
+
46
+
47
+ def post_commit(repo):
48
+ """Attach pending change entries to the commit that was just made. Infers nothing here (reconcile does)."""
49
+ from . import tree
50
+ n = 0
51
+ pdir = repo.cdir / "pending"
52
+ for p in sorted(pdir.glob("*.json")) if pdir.exists() else []:
53
+ try:
54
+ e = from_json(p.read_text(encoding="utf-8"))
55
+ e.anchor = tree.anchor_for(repo) # paths come from the commit itself
56
+ repo.add_private(e)
57
+ p.unlink()
58
+ n += 1
59
+ except (OSError, ValueError) as ex:
60
+ repo.error(f"post-commit {p.name}: {ex}")
61
+ return n
62
+
63
+
64
+ # ---- spool + detached worker ---------------------------------------------------------------------------------
65
+ @contextlib.contextmanager
66
+ def _lock(path, timeout=1.0):
67
+ """Tiny cross-platform lock (O_EXCL file, stale after 10 s). Yields False if it could not be taken."""
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ end, fd = time.time() + timeout, None
70
+ while time.time() < end:
71
+ try:
72
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
73
+ break
74
+ except FileExistsError:
75
+ try:
76
+ if time.time() - path.stat().st_mtime > 10:
77
+ path.unlink()
78
+ except OSError:
79
+ pass
80
+ time.sleep(0.02)
81
+ try:
82
+ yield fd is not None
83
+ finally:
84
+ if fd is not None:
85
+ os.close(fd)
86
+ try:
87
+ path.unlink()
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ def spawn_detached(args, cwd):
93
+ if os.environ.get("LOGBOOK_NO_SPAWN"): # tests
94
+ return
95
+ kw = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, cwd=str(cwd))
96
+ if os.name == "nt":
97
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
98
+ try: # escape the agent's job object if allowed, otherwise fall back
99
+ subprocess.Popen(args, creationflags=flags | 0x01000000, **kw) # CREATE_BREAKAWAY_FROM_JOB
100
+ except OSError:
101
+ subprocess.Popen(args, creationflags=flags, **kw)
102
+ else:
103
+ subprocess.Popen(args, start_new_session=True, **kw)
104
+
105
+
106
+ def spool_event(repo, event, agent, payload):
107
+ head, branch, paths, _ = g.state(repo.root) # one subprocess
108
+ item = {"event": event, "agent": agent, "t": time.time(), "head": head or "", "branch": branch,
109
+ "paths": paths[:40], "session": str(payload.get("session_id", ""))[:80], "transcript": str(payload.get("transcript_path", "")),
110
+ "prompt": str(payload.get("prompt", ""))[:2000], "last": str(payload.get("last_assistant_message", ""))[:4000]}
111
+ atomic_write(repo.cdir / "spool" / f"{int(time.time() * 1000):013d}-{os.urandom(3).hex()}.json", json.dumps(item))
112
+ if item["session"] and item["transcript"]: # copy the new bytes now: agents prune old transcripts
113
+ checkpoint(repo, item["session"], item["transcript"])
114
+
115
+
116
+ def handle_hook(event, agent, raw):
117
+ """Entry point for `logbook capture`. Returns text for stdout (may be ''). Never raises."""
118
+ repo = None
119
+ try:
120
+ payload = json.loads(raw) if raw and raw.strip() else {}
121
+ if not isinstance(payload, dict):
122
+ payload = {}
123
+ repo = Repo(payload.get("cwd") or ".")
124
+ cfg = repo.cfg()
125
+ if not cfg.get("user"):
126
+ return ""
127
+ out = ""
128
+ if event in ("SessionStart", "UserPromptSubmit", "PreToolUse"):
129
+ from . import recall # only the prompt-time hooks need retrieval; Stop/SessionEnd stay lean
130
+ files = []
131
+ ti = payload.get("tool_input")
132
+ if event == "PreToolUse" and isinstance(ti, dict) and ti.get("file_path"):
133
+ try:
134
+ files = [Path(ti["file_path"]).resolve().relative_to(repo.root.resolve()).as_posix()]
135
+ except (ValueError, OSError):
136
+ files = []
137
+ out = recall.hook_json(event, recall.digest(repo, event, payload.get("prompt", ""), files, str(payload.get("session_id", ""))))
138
+ if event in ("Stop", "PreCompact", "SessionEnd"): # prompt-time hooks stay read-only and fast
139
+ spool_event(repo, event, agent, payload)
140
+ if event in ("SessionStart", "PreCompact", "SessionEnd"):
141
+ spawn_detached([cfg.get("interpreter") or sys.executable, "-m", "getlogbook", "reconcile", "--quiet"], repo.root)
142
+ return out
143
+ except Exception as ex: # a hook must never break the agent
144
+ try:
145
+ (repo.error if repo else (lambda m: None))(f"capture {event}: {ex!r}")
146
+ except Exception:
147
+ pass
148
+ return ""
149
+
150
+
151
+ # ---- reconcile -----------------------------------------------------------------------------------------------
152
+ def _msg(obj):
153
+ m = obj.get("message") if isinstance(obj.get("message"), dict) else obj
154
+ role = m.get("role") or obj.get("type") or ""
155
+ c = m.get("content")
156
+ if isinstance(c, list):
157
+ c = " ".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text")
158
+ return role, c.strip() if isinstance(c, str) else ""
159
+
160
+
161
+ def _sid(sid):
162
+ return "".join(c for c in sid if c.isalnum() or c in "-_")[:80] or "nosession"
163
+
164
+
165
+ def _load(d):
166
+ try:
167
+ st = json.loads((d / "state.json").read_text(encoding="utf-8"))
168
+ except (OSError, ValueError):
169
+ st = {}
170
+ st.setdefault("offset", 0)
171
+ st.setdefault("paths", [])
172
+ st.setdefault("summarized", 0)
173
+ return st
174
+
175
+
176
+ def _copy_slice(d, st, transcript):
177
+ if transcript and Path(transcript).is_file():
178
+ size = Path(transcript).stat().st_size
179
+ if size < st["offset"]:
180
+ st["offset"] = 0
181
+ if size > st["offset"]:
182
+ with open(transcript, "rb") as f:
183
+ f.seek(st["offset"])
184
+ data = f.read()
185
+ d.mkdir(parents=True, exist_ok=True)
186
+ (d / f"slice-{st['offset']:012d}.jsonl").write_bytes(data)
187
+ st["offset"] = size
188
+
189
+
190
+ def checkpoint(repo, sid, transcript, paths=(), meta=None):
191
+ """Copy the transcript bytes added since the last checkpoint. Cheap; safe to call from a hook."""
192
+ d = repo.cdir / "sessions" / _sid(sid)
193
+ with _lock(d / ".lock") as got:
194
+ if not got:
195
+ return None
196
+ st = _load(d)
197
+ _copy_slice(d, st, transcript)
198
+ st["paths"] = sorted(set(st["paths"]) | set(paths))[:200]
199
+ st["last"] = time.time()
200
+ st.update(meta or {})
201
+ atomic_write(d / "state.json", json.dumps(st))
202
+ return d, st
203
+
204
+
205
+ def _finalize(repo, sid, d, st, agent, head, branch):
206
+ from . import tree
207
+ texts = []
208
+ for sl in sorted(d.glob("slice-*.jsonl")):
209
+ if int(sl.stem.split("-")[1]) < st["summarized"]:
210
+ continue
211
+ for line in sl.read_text(encoding="utf-8", errors="replace").splitlines():
212
+ try:
213
+ role, text = _msg(json.loads(line))
214
+ except ValueError:
215
+ continue
216
+ if text:
217
+ texts.append((role, text))
218
+ assistant = [t for r, t in texts if r == "assistant"]
219
+ user = [t for r, t in texts if r == "user"]
220
+ if not assistant:
221
+ return None
222
+ st["summarized"] = st["offset"]
223
+ first_line = lambda s, n: (s.splitlines() or [""])[0][:n]
224
+ e = Entry(id=new_id(), kind="finding", topic=(first_line(user[0], 80) if user else f"session {sid[:8]}"),
225
+ body={"summary": assistant[-1][:300], "what": "\n".join("- " + first_line(a, 120) for a in assistant[:5])},
226
+ provenance="inferred", captured_via=agent or "agent", created=now())
227
+ a = tree.anchor_for(repo, sha=head or None, paths=st["paths"][:40], dirty=bool(st["paths"]))
228
+ a["branch"] = branch or a["branch"]
229
+ e.anchor = a
230
+ repo.add_private(e)
231
+ return e
232
+
233
+
234
+ def reconcile(repo, quiet=True):
235
+ """Process spooled hook events, recover crashed sessions, and record commits that have no entry."""
236
+ done = {"drafts": 0, "floor": 0, "pending": 0}
237
+ sessions = {}
238
+ spool = repo.cdir / "spool"
239
+ for p in sorted(spool.glob("*.json")) if spool.exists() else []:
240
+ try:
241
+ it = json.loads(p.read_text(encoding="utf-8"))
242
+ except (OSError, ValueError):
243
+ p.unlink(missing_ok=True)
244
+ continue
245
+ sid = it.get("session") or "nosession"
246
+ s = sessions.setdefault(sid, {"agent": it.get("agent", ""), "transcript": "", "final": False, "head": "", "branch": "", "paths": set()})
247
+ s["transcript"] = it.get("transcript") or s["transcript"]
248
+ s["head"], s["branch"] = it.get("head") or s["head"], it.get("branch") or s["branch"]
249
+ s["paths"] |= set(it.get("paths", []))
250
+ s["final"] |= it.get("event") in ("PreCompact", "SessionEnd")
251
+ p.unlink(missing_ok=True)
252
+ for sid, s in sessions.items():
253
+ safe = _sid(sid)
254
+ got = checkpoint(repo, safe, s["transcript"], s["paths"], {"agent": s["agent"], "head": s["head"], "branch": s["branch"]})
255
+ if got and s["final"]:
256
+ d = got[0]
257
+ with _lock(d / ".lock") as ok:
258
+ st = _load(d)
259
+ if ok and _finalize(repo, safe, d, st, s["agent"], s["head"], s["branch"]):
260
+ done["drafts"] += 1
261
+ st["paths"] = []
262
+ if ok:
263
+ atomic_write(d / "state.json", json.dumps(st))
264
+ # crash recovery: sessions with unsummarised bytes and no activity for 30 minutes
265
+ sdir = repo.cdir / "sessions"
266
+ for d in sdir.iterdir() if sdir.exists() else []:
267
+ if not d.is_dir():
268
+ continue
269
+ with _lock(d / ".lock") as ok:
270
+ st = _load(d)
271
+ if ok and st["offset"] > st["summarized"] and time.time() - st.get("last", 0) > 1800:
272
+ if _finalize(repo, d.name, d, st, st.get("agent", ""), st.get("head", ""), st.get("branch", "")):
273
+ done["drafts"] += 1
274
+ atomic_write(d / "state.json", json.dumps(st))
275
+ done["pending"] = post_commit(repo) if (repo.cdir / "pending").exists() else 0
276
+ done["floor"] = _commit_floor(repo)
277
+ return done
278
+
279
+
280
+ def _commit_floor(repo):
281
+ """Inferred entries for my own commits since init that have no entry yet (covers web-UI/hook-less commits)."""
282
+ from . import tree
283
+ base = repo.cfg().get("floor_base")
284
+ if not base or not g.commit_exists(repo.root, base):
285
+ return 0
286
+ email = g.config_get(repo.root, "user.email")
287
+ rc, out, _ = g.try_run(["log", "--first-parent", "-n50", f"--author={email}", "--format=%H", f"{base}..HEAD"], repo.root)
288
+ covered = {l.entry.anchor.get("sha") for l in repo.all_entries()}
289
+ n = 0
290
+ for sha in (out.split() if rc == 0 else []):
291
+ if sha in covered:
292
+ continue
293
+ info = g.commit_info(repo.root, sha) or {}
294
+ paths = g.changed_paths(repo.root, sha)
295
+ body = g.try_run(["show", "-s", "--format=%b", sha], repo.root)[1].strip()
296
+ layers = sorted({l for l in (tree.layer_of(p) for p in paths) if l})
297
+ e = Entry(id=new_id(), kind="change", topic=info.get("subject", "")[:200],
298
+ body={"summary": info.get("subject", ""), "what": (body + f"\nfiles: {len(paths)}" +
299
+ (f" ({', '.join(layers)})" if layers else "")).strip()},
300
+ provenance="inferred", captured_via="reconcile", created=now())
301
+ e.anchor = tree.anchor_for(repo, sha=sha, paths=paths)
302
+ repo.add_private(e)
303
+ n += 1
304
+ return n
305
+
306
+
307
+ # ---- installers ---------------------------------------------------------------------------------------------
308
+ def _py(repo):
309
+ return (repo.cfg().get("interpreter") or sys.executable).replace("\\", "/")
310
+
311
+
312
+ def install_git_hook(repo):
313
+ hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
314
+ hooks.mkdir(parents=True, exist_ok=True)
315
+ f = hooks / "post-commit"
316
+ block = f'{MARK_BEGIN}\n"{_py(repo)}" -m getlogbook post-commit >/dev/null 2>&1 || true\n{MARK_END}\n'
317
+ text = f.read_text(encoding="utf-8") if f.exists() else "#!/bin/sh\n"
318
+ if MARK_BEGIN not in text: # chain, never overwrite
319
+ f.write_text(text.rstrip("\n") + "\n" + block, encoding="utf-8", newline="\n")
320
+ try:
321
+ f.chmod(f.stat().st_mode | 0o111)
322
+ except OSError:
323
+ pass
324
+ c = repo.cfg()
325
+ c.setdefault("floor_base", g.head_sha(repo.root) or "")
326
+ repo.save_cfg(c)
327
+ return f
328
+
329
+
330
+ def uninstall_git_hook(repo):
331
+ hooks = Path(g.run(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], repo.root).strip())
332
+ f = hooks / "post-commit"
333
+ if f.exists() and MARK_BEGIN in f.read_text(encoding="utf-8"):
334
+ t = f.read_text(encoding="utf-8")
335
+ a, _, rest = t.partition(MARK_BEGIN)
336
+ _, _, b = rest.partition(MARK_END)
337
+ f.write_text((a + b.lstrip("\n")).rstrip("\n") + "\n", encoding="utf-8", newline="\n")
338
+ return f
339
+
340
+
341
+ def claude_snippet(repo, pretool=False):
342
+ py = _py(repo)
343
+ mk = lambda ev: [{"hooks": [{"type": "command", "command": f'"{py}" -m getlogbook capture --event {ev} --agent claude-code'}]}]
344
+ hooks = {ev: mk(ev) for ev in CLAUDE_EVENTS}
345
+ if pretool:
346
+ hooks["PreToolUse"] = [{"matcher": "Edit|Write|MultiEdit", **mk("PreToolUse")[0]}]
347
+ return {"hooks": hooks}
348
+
349
+
350
+ def install_claude(repo, path=None, pretool=False, write=False, remove=False):
351
+ path = Path(path or repo.root / ".claude" / "settings.local.json") # local: it holds a machine-specific interpreter path
352
+ try:
353
+ cur = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
354
+ except ValueError:
355
+ raise SystemExit(f"logbook: {path} is not valid JSON; refusing to touch it")
356
+ hooks = cur.setdefault("hooks", {})
357
+ snippet = claude_snippet(repo, pretool)["hooks"]
358
+ for ev, entries in list(hooks.items()): # always drop our previous entries so install is idempotent
359
+ keep = [e for e in entries if "getlogbook capture" not in json.dumps(e)]
360
+ if keep:
361
+ hooks[ev] = keep
362
+ else:
363
+ del hooks[ev]
364
+ if not remove:
365
+ for ev, entries in snippet.items():
366
+ hooks.setdefault(ev, []).extend(entries)
367
+ if not hooks:
368
+ cur.pop("hooks")
369
+ if write:
370
+ path.parent.mkdir(parents=True, exist_ok=True)
371
+ atomic_write(path, json.dumps(cur, indent=2))
372
+ return path, cur