agent-handover 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.
@@ -0,0 +1,12 @@
1
+ """agent-handover — session handover engine for AI coding agents.
2
+
3
+ Checkpointed, resumable, backend-agnostic persistent memory so an AI agent
4
+ can end a session and the next session can pick up exactly where it left off.
5
+ """
6
+ from agent_handover.checkpoint import Checkpoint
7
+ from agent_handover.memory import MemoryStore
8
+ from agent_handover.engine import HandoverEngine, Step
9
+ from agent_handover.backends import GitBackend, NullBackend
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = ["Checkpoint", "MemoryStore", "HandoverEngine", "Step", "GitBackend", "NullBackend"]
@@ -0,0 +1,62 @@
1
+ """Publish backends.
2
+
3
+ The MemoryStore writes plain files; a backend decides what "persist" means
4
+ beyond the local disk. GitBackend commits and pushes the memory root so the
5
+ state survives machine loss and is shareable across machines/agents.
6
+
7
+ Implement the Backend protocol to add others (S3, NotebookLM, vector DB...).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Protocol
14
+
15
+
16
+ class Backend(Protocol):
17
+ def publish(self, message: str, dry_run: bool = False) -> bool: ...
18
+
19
+
20
+ class NullBackend:
21
+ """Local-only: files on disk are enough."""
22
+
23
+ def publish(self, message: str, dry_run: bool = False) -> bool:
24
+ return True
25
+
26
+
27
+ class GitBackend:
28
+ def __init__(self, repo_root: Path | str, paths: list[str] | None = None,
29
+ branch: str = "main", remote: str = "origin",
30
+ push: bool = True, timeout: int = 60):
31
+ self.repo_root = Path(repo_root)
32
+ self.paths = paths or ["."]
33
+ self.branch = branch
34
+ self.remote = remote
35
+ self.push = push
36
+ self.timeout = timeout
37
+
38
+ def _git(self, *args: str, timeout: int | None = None) -> subprocess.CompletedProcess:
39
+ return subprocess.run(
40
+ ["git", "-C", str(self.repo_root), *args],
41
+ capture_output=True, text=True, timeout=timeout or self.timeout,
42
+ )
43
+
44
+ def publish(self, message: str, dry_run: bool = False) -> bool:
45
+ if dry_run:
46
+ return True
47
+ try:
48
+ r = self._git("add", *self.paths)
49
+ if r.returncode != 0:
50
+ return False
51
+ r = self._git("commit", "-m", message)
52
+ if r.returncode != 0:
53
+ combined = r.stdout + r.stderr
54
+ if "nothing to commit" in combined:
55
+ return True # no changes is a success, not a failure
56
+ return False
57
+ if self.push:
58
+ r = self._git("push", self.remote, self.branch)
59
+ return r.returncode == 0
60
+ return True
61
+ except subprocess.TimeoutExpired:
62
+ return False
@@ -0,0 +1,79 @@
1
+ """Crash-safe checkpoint for multi-step handover runs.
2
+
3
+ A handover that dies halfway is worse than no handover at all: the next
4
+ session inherits a half-written state. Every step is recorded here so an
5
+ interrupted run can be detected and resumed.
6
+
7
+ Exit-code contract (used by ``agent-handover check``):
8
+ 0 = no resume needed (no run in progress)
9
+ 1 = resume needed (a run was interrupted; pending steps remain)
10
+ 2 = last run completed
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ RESUME_NONE = 0
19
+ RESUME_NEEDED = 1
20
+ RESUME_COMPLETED = 2
21
+
22
+
23
+ class Checkpoint:
24
+ def __init__(self, path: Path | str):
25
+ self.path = Path(path)
26
+
27
+ # -- persistence ---------------------------------------------------
28
+ def load(self) -> dict:
29
+ if self.path.exists():
30
+ with open(self.path, encoding="utf-8") as f:
31
+ return json.load(f)
32
+ return {"status": "none", "steps": {}, "started_at": None, "finished_at": None}
33
+
34
+ def _save(self, data: dict) -> None:
35
+ self.path.parent.mkdir(parents=True, exist_ok=True)
36
+ tmp = self.path.with_suffix(".tmp")
37
+ with open(tmp, "w", encoding="utf-8") as f:
38
+ json.dump(data, f, ensure_ascii=False, indent=2)
39
+ tmp.replace(self.path) # atomic on POSIX
40
+
41
+ # -- lifecycle -----------------------------------------------------
42
+ def start(self, step_names: list[str]) -> None:
43
+ data = self.load()
44
+ if data["status"] == "in_progress":
45
+ # keep existing progress; only add unknown steps
46
+ for name in step_names:
47
+ data["steps"].setdefault(name, "pending")
48
+ else:
49
+ data = {
50
+ "status": "in_progress",
51
+ "steps": {name: "pending" for name in step_names},
52
+ "started_at": datetime.now(timezone.utc).isoformat(),
53
+ "finished_at": None,
54
+ }
55
+ self._save(data)
56
+
57
+ def mark_done(self, step_name: str) -> None:
58
+ data = self.load()
59
+ data["steps"][step_name] = "done"
60
+ self._save(data)
61
+
62
+ def complete(self) -> None:
63
+ data = self.load()
64
+ data["status"] = "completed"
65
+ data["finished_at"] = datetime.now(timezone.utc).isoformat()
66
+ self._save(data)
67
+
68
+ # -- inspection ----------------------------------------------------
69
+ def pending_steps(self) -> list[str]:
70
+ data = self.load()
71
+ return [k for k, v in data.get("steps", {}).items() if v != "done"]
72
+
73
+ def resume_code(self) -> int:
74
+ data = self.load()
75
+ if data["status"] == "completed":
76
+ return RESUME_COMPLETED
77
+ if data["status"] == "in_progress":
78
+ return RESUME_NEEDED if self.pending_steps() else RESUME_COMPLETED
79
+ return RESUME_NONE
agent_handover/cli.py ADDED
@@ -0,0 +1,39 @@
1
+ """CLI: agent-handover check|status (run is wired up by your own script)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from agent_handover.checkpoint import Checkpoint
9
+
10
+ DEFAULT_CHECKPOINT = Path(".agent-handover/checkpoint.json")
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(prog="agent-handover")
15
+ parser.add_argument("command", choices=["check", "status"])
16
+ parser.add_argument("--checkpoint", type=Path, default=DEFAULT_CHECKPOINT)
17
+ args = parser.parse_args(argv)
18
+
19
+ cp = Checkpoint(args.checkpoint)
20
+
21
+ if args.command == "check":
22
+ return cp.resume_code()
23
+
24
+ if args.command == "status":
25
+ data = cp.load()
26
+ print(f"status: {data['status']}")
27
+ for name, state in data.get("steps", {}).items():
28
+ mark = "x" if state == "done" else " "
29
+ print(f" [{mark}] {name}")
30
+ pending = cp.pending_steps()
31
+ if data["status"] == "in_progress" and pending:
32
+ print(f"pending: {', '.join(pending)}")
33
+ return 0
34
+
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())
@@ -0,0 +1,69 @@
1
+ """Handover engine: run named steps under a checkpoint, resume on failure.
2
+
3
+ Design rules learned from running this in production:
4
+
5
+ 1. Every step is checkpointed — a crash mid-handover must be detectable
6
+ and resumable, never silently half-done.
7
+ 2. A PAUSE file is an absolute kill-switch for all automation. Agents that
8
+ write to your repos need a brake the human can pull without an editor.
9
+ 3. Remote layers fail; the local filesystem copy is the fallback that the
10
+ next session can always boot from.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from agent_handover.checkpoint import Checkpoint
19
+
20
+
21
+ @dataclass
22
+ class Step:
23
+ name: str
24
+ run: Callable[[], None]
25
+
26
+
27
+ class PauseError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class StepError(RuntimeError):
32
+ def __init__(self, step_name: str, cause: Exception):
33
+ super().__init__(f"step '{step_name}' failed: {cause}")
34
+ self.step_name = step_name
35
+ self.cause = cause
36
+
37
+
38
+ class HandoverEngine:
39
+ def __init__(self, steps: list[Step], checkpoint: Checkpoint,
40
+ pause_file: Path | str | None = None):
41
+ self.steps = steps
42
+ self.checkpoint = checkpoint
43
+ self.pause_file = Path(pause_file) if pause_file else None
44
+
45
+ def _check_pause(self) -> None:
46
+ if self.pause_file and self.pause_file.exists():
47
+ raise PauseError(f"automation paused: remove {self.pause_file} to resume")
48
+
49
+ def run(self, resume: bool = True) -> list[str]:
50
+ """Run all (or pending) steps. Returns the list of steps executed."""
51
+ self._check_pause()
52
+
53
+ pending = set(self.checkpoint.pending_steps()) if resume else None
54
+ self.checkpoint.start([s.name for s in self.steps])
55
+
56
+ executed: list[str] = []
57
+ for step in self.steps:
58
+ if resume and pending is not None and pending and step.name not in pending:
59
+ continue # already done in the interrupted run
60
+ self._check_pause()
61
+ try:
62
+ step.run()
63
+ except Exception as exc: # checkpoint keeps this step pending
64
+ raise StepError(step.name, exc) from exc
65
+ self.checkpoint.mark_done(step.name)
66
+ executed.append(step.name)
67
+
68
+ self.checkpoint.complete()
69
+ return executed
@@ -0,0 +1,67 @@
1
+ """Three-layer persistent memory store, written as plain Markdown.
2
+
3
+ Layer 1 session notes — what happened in one session (append, dated)
4
+ Layer 2 current state — single always-overwritten snapshot ("where are we now")
5
+ Layer 3 consolidated — monthly compaction of old Layer-1 notes
6
+
7
+ Everything is plain files under one root, so the store is git-friendly,
8
+ diff-able, and readable by humans and by any agent that can read a file.
9
+ A remote/semantic layer (vector DB, NotebookLM, etc.) can sit on top, but
10
+ the filesystem copy is always the source of truth and the fallback.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ HEADER = "<!-- agent-handover | layer{layer} | {date} -->\n"
18
+
19
+
20
+ class MemoryStore:
21
+ def __init__(self, root: Path | str):
22
+ self.root = Path(root)
23
+
24
+ # -- write ----------------------------------------------------------
25
+ def write(self, layer: int, content: str, tag: str = "general",
26
+ date_str: str | None = None, dry_run: bool = False) -> Path:
27
+ if layer not in (1, 2, 3):
28
+ raise ValueError(f"invalid layer: {layer}")
29
+ date_str = date_str or datetime.now().strftime("%Y-%m-%d")
30
+ ym = date_str[:7]
31
+ safe_tag = tag.replace("/", "-").replace(" ", "_")
32
+
33
+ if layer == 1:
34
+ target = self.root / "layer1" / ym / f"{safe_tag}-{date_str}.md"
35
+ elif layer == 2:
36
+ target = self.root / "layer2" / "current-state.md"
37
+ else:
38
+ target = self.root / "layer3" / f"{ym}-{safe_tag}-archive.md"
39
+
40
+ if not dry_run:
41
+ target.parent.mkdir(parents=True, exist_ok=True)
42
+ target.write_text(
43
+ HEADER.format(layer=layer, date=date_str) + content,
44
+ encoding="utf-8",
45
+ )
46
+ return target
47
+
48
+ # -- read (fallback path when no remote layer is available) ---------
49
+ def read_latest(self, layer: int, tag: str | None = None) -> str:
50
+ if layer == 2:
51
+ current = self.root / "layer2" / "current-state.md"
52
+ return current.read_text(encoding="utf-8") if current.exists() else ""
53
+
54
+ sub = self.root / f"layer{layer}"
55
+ if not sub.exists():
56
+ return ""
57
+ files = sorted(sub.rglob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
58
+ if tag:
59
+ files = [f for f in files if tag in f.name]
60
+ return files[0].read_text(encoding="utf-8") if files else ""
61
+
62
+ # -- compaction ------------------------------------------------------
63
+ def layer1_note_count(self, ym: str | None = None) -> int:
64
+ sub = self.root / "layer1"
65
+ if ym:
66
+ sub = sub / ym
67
+ return len(list(sub.rglob("*.md"))) if sub.exists() else 0
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-handover
3
+ Version: 0.1.0
4
+ Summary: Session handover engine for AI coding agents: checkpointed, resumable, backend-agnostic persistent memory.
5
+ Project-URL: Homepage, https://github.com/hikari716/agent-handover
6
+ Project-URL: Issues, https://github.com/hikari716/agent-handover/issues
7
+ Author: Hiroyuki Nagashima
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agents,claude,codex,context,handover,llm,memory
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # agent-handover
19
+
20
+ **Session handover engine for AI coding agents: checkpointed, resumable, backend-agnostic persistent memory.**
21
+
22
+ AI coding agents (Claude Code, Codex, OpenCode, Cline, ...) are stateless between
23
+ sessions. Every new session starts with re-explaining the project, the decisions
24
+ already made, and what was left half-done. `agent-handover` is the small piece of
25
+ infrastructure that fixes this: at the end of a session the agent writes a
26
+ structured handover; at the start of the next one, it resumes from it — even if
27
+ the previous handover crashed halfway.
28
+
29
+ Extracted from a personal "AI Team OS" that has run daily handovers across
30
+ multiple machines and agents since 2025.
31
+
32
+ ## The problem
33
+
34
+ - **Context loss**: the next session doesn't know what the last one decided.
35
+ - **Half-written state**: a handover that dies mid-run silently corrupts memory.
36
+ The next session boots from a state that is *partly* updated — worse than stale.
37
+ - **Remote memory is fragile**: vector DBs and hosted notebooks go down.
38
+ If your agent's memory has no local fallback, your agent has no memory.
39
+
40
+ ## Design
41
+
42
+ ```
43
+ ┌─ session ends ──────────────────────────────────────────────┐
44
+ │ HandoverEngine(steps, checkpoint, pause_file) │
45
+ │ step 1: collect session facts ── checkpointed │
46
+ │ step 2: MemoryStore.write(layer=1...) ── checkpointed │
47
+ │ step 3: MemoryStore.write(layer=2...) ── checkpointed │
48
+ │ step 4: GitBackend.publish(...) ── checkpointed │
49
+ └──────────────────────────────────────────────────────────────┘
50
+ ┌─ next session starts ───────────────────────────────────────┐
51
+ │ $ agent-handover check # 0=clean 1=RESUME 2=completed │
52
+ │ read layer2/current-state.md → agent has context again │
53
+ └──────────────────────────────────────────────────────────────┘
54
+ ```
55
+
56
+ **Three memory layers, all plain Markdown** (git-friendly, diff-able, readable
57
+ by humans and any agent that can read a file):
58
+
59
+ | Layer | File pattern | Semantics |
60
+ |---|---|---|
61
+ | 1 | `layer1/YYYY-MM/<tag>-<date>.md` | session notes (append, dated) |
62
+ | 2 | `layer2/current-state.md` | "where are we now" (always overwritten) |
63
+ | 3 | `layer3/YYYY-MM-<tag>-archive.md` | monthly compaction of old notes |
64
+
65
+ **Three rules learned in production:**
66
+
67
+ 1. *Every step is checkpointed.* An interrupted handover is detected
68
+ (`agent-handover check` → exit 1) and resumed without re-running done steps.
69
+ 2. *A `PAUSE` file is an absolute kill-switch.* Agents that write to your repos
70
+ need a brake a human can pull with `touch PAUSE`.
71
+ 3. *The filesystem is the source of truth.* Remote layers (NotebookLM, vector
72
+ stores) are optional accelerators; the local copy is always enough to boot.
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ pip install agent-handover # or: pip install -e . from a clone
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ ```python
83
+ from pathlib import Path
84
+ from agent_handover import Checkpoint, HandoverEngine, MemoryStore, Step, GitBackend
85
+
86
+ store = MemoryStore("memory/")
87
+ cp = Checkpoint(".agent-handover/checkpoint.json")
88
+ git = GitBackend(".", paths=["memory/"])
89
+
90
+ engine = HandoverEngine(
91
+ steps=[
92
+ Step("session_note", lambda: store.write(1, notes, tag="refactor")),
93
+ Step("current_state", lambda: store.write(2, snapshot)),
94
+ Step("publish", lambda: git.publish("handover: session sync")),
95
+ ],
96
+ checkpoint=cp,
97
+ pause_file=Path.home() / ".agent_handover" / "PAUSE",
98
+ )
99
+ engine.run() # resumes pending steps automatically after a crash
100
+ ```
101
+
102
+ In your agent's bootstrap (CLAUDE.md / AGENTS.md):
103
+
104
+ ```bash
105
+ agent-handover check # exit 1 → finish the interrupted handover first
106
+ ```
107
+
108
+ ## Status & roadmap
109
+
110
+ - [x] checkpointed engine, 3-layer Markdown store, git backend, pause guardrail
111
+ - [ ] handover quality scoring (was the note actually useful next session?)
112
+ - [ ] adapters: NotebookLM, sqlite-vec
113
+ - [ ] `agent-handover run` with declarative step config (TOML)
114
+
115
+ Issues and PRs welcome — especially reports from other agent stacks
116
+ (Codex CLI, Cline, OpenCode).
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,11 @@
1
+ agent_handover/__init__.py,sha256=YJfKCSEZewy8DN7Lx49FylvPI2ZJO0uKOiJiByenu20,554
2
+ agent_handover/backends.py,sha256=2qh_iXG6qjEXlgOe909e7fD6iusUoiY-1lNAPIYsmTQ,2105
3
+ agent_handover/checkpoint.py,sha256=zbrtpY-AssS_1o-IyupdM1u7RcUwcU5OBikG1DqUfTQ,2819
4
+ agent_handover/cli.py,sha256=y22JH8qKLD37HiBCg0f815WEJd4Z99iLmIx_CUNFP20,1146
5
+ agent_handover/engine.py,sha256=1OO_jW9y6wljd-AT700XTBN-CrOXIBmk5oLpORQR7sc,2342
6
+ agent_handover/memory.py,sha256=qNaZsn3kuQ_A47-AkJ-rD9_N-weyTGn_Jl_B5t6sFPg,2760
7
+ agent_handover-0.1.0.dist-info/METADATA,sha256=HeDf9Lx4J1pr4skvTPDG0Qo3wKXFbtP3EhGc8-OmWU4,5184
8
+ agent_handover-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ agent_handover-0.1.0.dist-info/entry_points.txt,sha256=KRE82jpEpC5NCJgY6YAen4bi0-HadC8AjgZ0FLgrDDg,59
10
+ agent_handover-0.1.0.dist-info/licenses/LICENSE,sha256=XT4USyOSLF7IeMcTHQh9FNXA_ZOiaYbXpUnVevWRcNQ,1075
11
+ agent_handover-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-handover = agent_handover.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hiroyuki Nagashima
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.