Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ CLI flags (`--note`, `--current-state`, `--no-push`, `--checkpoint`) override th
file, so one config serves every session. See
[`examples/run/`](./examples/run/) for a starter `handover.toml`.

To preview a run before wiring it into a hook, `--dry-run` prints the resolved
plan (memory_dir, tag, checkpoint, backend, step order) and exits 0 without
executing any step — no file writes, no git:

```bash
agent-handover run --config .agent-handover/handover.toml --dry-run
```

## Use with Codex CLI

A first-class adapter for the [OpenAI Codex CLI](https://developers.openai.com/codex/cli).
Expand Down
13 changes: 12 additions & 1 deletion src/agent_handover/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def _build_parser() -> argparse.ArgumentParser:
p_run.add_argument("--no-push", action="store_true", help="force local-only (no git push)")
p_run.add_argument("--checkpoint", type=Path, default=None,
help="override [handover].checkpoint")
p_run.add_argument("--dry-run", action="store_true",
help="print the resolved plan and exit without executing any step")
return parser


Expand All @@ -58,9 +60,18 @@ def main(argv: list[str] | None = None) -> int:

if args.command == "run":
# Imported lazily so `check`/`status` don't require the TOML parser.
from agent_handover.config import build_engine_from_config, load_config
from agent_handover.config import build_engine_from_config, load_config, resolve_plan

config = load_config(args.config)
if args.dry_run:
plan = resolve_plan(config, checkpoint=args.checkpoint)
print("dry run (no steps executed):")
print(f" memory_dir: {plan['memory_dir']}")
print(f" tag: {plan['tag']}")
print(f" checkpoint: {plan['checkpoint']}")
print(f" backend: {plan['backend']}")
print(f" steps: {', '.join(plan['steps'])}")
return 0
engine = build_engine_from_config(
config,
note=args.note,
Expand Down
25 changes: 25 additions & 0 deletions src/agent_handover/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,38 @@

DEFAULT_CHECKPOINT = ".agent-handover/checkpoint.json"

#: Ordered step names of a declarative handover (see build_engine_from_config).
PLAN_STEPS = ("session_note", "current_state", "publish")


def load_config(path: Path | str) -> dict[str, Any]:
"""Parse a TOML handover config file into a dict."""
with Path(path).open("rb") as fh:
return tomllib.load(fh)


def resolve_plan(
config: dict[str, Any],
*,
checkpoint: Path | str | None = None,
) -> dict[str, Any]:
"""Resolve what a run would do, without building or executing anything.

Returns the effective ``memory_dir``, ``tag``, ``checkpoint``, ``backend``
type, and the ordered step names — the plan printed by
``agent-handover run --dry-run``. ``checkpoint`` (CLI ``--checkpoint``)
overrides ``[handover].checkpoint``, mirroring ``build_engine_from_config``.
"""
handover = config.get("handover", {})
return {
"memory_dir": str(handover.get("memory_dir", "memory")),
"tag": handover.get("tag", "general"),
"checkpoint": str(checkpoint or handover.get("checkpoint", DEFAULT_CHECKPOINT)),
"backend": config.get("backend", {}).get("type", "git"),
"steps": list(PLAN_STEPS),
}


def build_engine_from_config(
config: dict[str, Any],
*,
Expand Down
29 changes: 29 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,32 @@ def test_run_note_from_config_when_flag_absent(tmp_path):
cfg = _write_config(tmp_path, mem, cp)
assert main(["run", "--config", str(cfg)]) == 0
assert "from file" in (mem / "layer2" / "current-state.md").read_text(encoding="utf-8")


def test_run_dry_run_prints_plan_and_writes_nothing(tmp_path, capsys):
mem = tmp_path / "memory"
cp = tmp_path / "cp.json"
cfg = _write_config(tmp_path, mem, cp)

assert main(["run", "--config", str(cfg), "--dry-run"]) == 0

out = capsys.readouterr().out
assert f"memory_dir: {mem}" in out
assert "tag: codex" in out
assert f"checkpoint: {cp}" in out
assert "backend: null" in out
assert "steps: session_note, current_state, publish" in out
# nothing was executed: no memory dir, no checkpoint file
assert not mem.exists()
assert not cp.exists()


def test_run_dry_run_shows_checkpoint_override(tmp_path, capsys):
mem = tmp_path / "memory"
cfg = _write_config(tmp_path, mem, tmp_path / "cp.json")
override = tmp_path / "other-cp.json"

assert main(["run", "--config", str(cfg), "--dry-run", "--checkpoint", str(override)]) == 0

assert f"checkpoint: {override}" in capsys.readouterr().out
assert not override.exists()
30 changes: 30 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
_build_backend,
build_engine_from_config,
load_config,
resolve_plan,
)
from agent_handover.backends import GitBackend, NullBackend

Expand Down Expand Up @@ -37,6 +38,35 @@ def test_build_engine_writes_memory_and_completes(tmp_path):
assert Checkpoint(cp).resume_code() == RESUME_COMPLETED


def test_resolve_plan_defaults_and_overrides(tmp_path):
assert resolve_plan({}) == {
"memory_dir": "memory",
"tag": "general",
"checkpoint": ".agent-handover/checkpoint.json",
"backend": "git",
"steps": ["session_note", "current_state", "publish"],
}
config = {
"handover": {"memory_dir": "mem", "tag": "codex", "checkpoint": "cp.json"},
"backend": {"type": "null"},
}
plan = resolve_plan(config, checkpoint=tmp_path / "override.json")
assert plan["memory_dir"] == "mem"
assert plan["tag"] == "codex"
assert plan["checkpoint"] == str(tmp_path / "override.json")
assert plan["backend"] == "null"


def test_resolve_plan_matches_engine_step_order(tmp_path):
config = {
"handover": {"memory_dir": str(tmp_path / "memory")},
"note": {"session": "hi"},
"backend": {"type": "null"},
}
engine = build_engine_from_config(config)
assert resolve_plan(config)["steps"] == [s.name for s in engine.steps]


def test_cli_note_overrides_config(tmp_path):
mem = tmp_path / "memory"
config = {
Expand Down