diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 24823b0..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,9 +0,0 @@ -# Global owners — assigned to all PRs by default -# Replace with your team's GitHub usernames or team handles - -* @your-org/your-team - -# Specific path overrides -# /.github/ @your-org/devops -# /docs/ @your-org/docs-team -# /src/ml/ @your-org/ml-team diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 423d83f..fa971f9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,36 +1,34 @@ # Contributing -Thanks for taking the time to contribute! Please follow these guidelines. +This branch ships the handoff skills and a shell installer. ## Branching -- Branch off `main` for all changes -- Use descriptive branch names: `feat/add-auth`, `fix/null-pointer`, `chore/update-deps` +- Branch off `master` for skill and installer changes. +- Keep changes focused and reviewable. -## Commit Messages +## Verification -Follow [Conventional Commits](https://www.conventionalcommits.org/): +Before opening a pull request, run: +```bash +bash -n install.sh +tmp="$(mktemp -d)" +./install.sh both --home "$tmp" --mode copy ``` -feat: add user login endpoint -fix: handle empty response from model -chore: bump dependencies -docs: update API reference -``` - -## Pull Requests -- Keep PRs small and focused (one concern per PR) -- Fill out the PR template fully -- All CI checks must pass before requesting review -- At least 1 approval required to merge +Then confirm these files exist: -## Code Style - -- Follow the `.editorconfig` settings -- Run the linter before pushing -- Write tests for new features +```text +$tmp/.codex/skills/handoff/SKILL.md +$tmp/.codex/skills/get-handoff/SKILL.md +$tmp/.claude/skills/handoff/SKILL.md +$tmp/.claude/skills/get-handoff/SKILL.md +``` -## Reporting Issues +## Pull Requests -Use the issue templates provided — bug reports and feature requests have separate forms. +- Fill out the PR template. +- Include the verification you ran. +- Do not commit local `.handoff/`, `.omx/`, `.omc/`, or runtime install + directories. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 33e4f97..2ad6035 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,29 +1,29 @@ ## What does this PR do? - + ## Type of change -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor / cleanup -- [ ] Docs / chore +- [ ] Skill behavior +- [ ] Installer +- [ ] Documentation +- [ ] GitHub metadata / CI - [ ] Breaking change ## Related issues Closes # -## Testing +## Verification - + -- [ ] Unit tests added / updated -- [ ] Manually tested +- [ ] `bash -n install.sh` +- [ ] `./install.sh both --home "$(mktemp -d)" --mode copy` +- [ ] Manually reviewed installed `SKILL.md` files ## Checklist -- [ ] My code follows the project's style guidelines -- [ ] I've reviewed my own diff -- [ ] I've added comments where the code is non-obvious -- [ ] No new secrets or credentials committed +- [ ] The README still matches the install flow +- [ ] Skill files remain under `skills//SKILL.md` +- [ ] No local `.handoff/`, `.omx/`, `.omc/`, `.codex`, or `.claude` state is committed diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f904cbf..9841a03 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,5 @@ version: 2 updates: - - package-ecosystem: pip - directory: "/" - schedule: - interval: weekly - day: monday - open-pull-requests-limit: 5 - labels: - - dependencies - - package-ecosystem: github-actions directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39ac6cb..3ff0c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,31 +11,34 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: LintExpected + validate: + name: Validate skills and installer runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Compile Python sources - run: python -m compileall src tests - - test: - name: TestsExpected - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Run tests - run: PYTHONPATH=src python -m unittest discover -s tests -v + - name: Check shell syntax + run: bash -n install.sh + + - name: Check skill files + run: | + test -f skills/handoff/SKILL.md + test -f skills/get-handoff/SKILL.md + grep -q 'name: handoff' skills/handoff/SKILL.md + grep -q 'name: get-handoff' skills/get-handoff/SKILL.md + + - name: Smoke test copy install + run: | + tmp="$(mktemp -d)" + ./install.sh both --home "$tmp" --mode copy + test -f "$tmp/.codex/skills/handoff/SKILL.md" + test -f "$tmp/.codex/skills/get-handoff/SKILL.md" + test -f "$tmp/.claude/skills/handoff/SKILL.md" + test -f "$tmp/.claude/skills/get-handoff/SKILL.md" + + - name: Smoke test symlink install + run: | + tmp="$(mktemp -d)" + ./install.sh codex --home "$tmp" --mode symlink + test -L "$tmp/.codex/skills/handoff" + test -L "$tmp/.codex/skills/get-handoff" diff --git a/.gitignore b/.gitignore index 9e5dbc7..849b5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,119 +1,33 @@ -# ── Python ────────────────────────────────────────────── -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python +# Local runtime state +.handoff/ +.omx/ +.omc/ +.codex +.codex/ +.claude +.claude/ + +# Build and cache output build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +.venv/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST -*.pyo - -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -.python-version .pytest_cache/ -.mypy_cache/ -.ruff_cache/ -.coverage -htmlcov/ - -# ── ML / Data ──────────────────────────────────────────── -*.h5 -*.hdf5 -*.pkl -*.pickle -*.pt -*.pth -*.onnx -*.pb -*.ckpt -*.safetensors -data/raw/ -data/processed/ -models/ -checkpoints/ -runs/ -wandb/ -mlruns/ -.neptune/ -lightning_logs/ - -# Jupyter -.ipynb_checkpoints/ -*.ipynb - -# ── Node / JS ──────────────────────────────────────────── -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* -.yarn/cache -.yarn/unplugged -dist/ -.next/ -.nuxt/ -.output/ -.cache/ +__pycache__/ +*.py[cod] -# ── Editors ────────────────────────────────────────────── +# Editors and OS files .idea/ .vscode/ *.swp *.swo -*.sublime-project -*.sublime-workspace .DS_Store Thumbs.db -# ── Secrets / Credentials ──────────────────────────────── +# Secrets .env .env.* !.env.example *.pem *.key -*.p12 -*.pfx secrets/ - -# ── OS ─────────────────────────────────────────────────── -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# ── Infra / IaC ────────────────────────────────────────── -.terraform/ -*.tfstate -*.tfstate.* -.terraformrc -terraform.rc -.omx/ - -# ── Local agent / handoff helpers ──────────────────────── -.codex -.claude/ diff --git a/README.md b/README.md index 9d5fe8b..7f6be18 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,120 @@ # portable-handoff -Portable agent-centric handoff state for cross-agent resume. +Keep coding-agent work portable, resumable, and tool-neutral. -## Main Commands +`portable-handoff` gives Codex and Claude two small skills: -- `/handoff [agent?]` -- `/get-handoff A,B` +- `/handoff [agent?]` captures the current agent session into a durable snapshot. +- `/get-handoff A,B` merges one or more snapshots into a compact resume brief. -The product surface is skill-first. There is no end-user CLI. +Use it when work should survive model switches, context limits, interrupted +sessions, parallel agents, or handoffs between people and tools. -## Purpose +## Why It Exists -Use `/handoff` to save the current live agent state under a named agent snapshot, then use `/get-handoff A,B` in a new agent session to merge one or more prior agent snapshots into a resumable context. +Coding agents are powerful while they remember the work. They are fragile when +that memory lives only inside one chat window. -## Storage +`portable-handoff` moves the important state into files: + +- what the agent was trying to do +- what changed +- what still needs to happen +- what was already verified +- what constraints and decisions matter +- which files the next agent should read first + +The result is a practical handoff protocol for agentic development. It does not +try to preserve hidden model state. It preserves the parts a future agent can +actually inspect, trust, and continue from. + +## When To Use It + +Use `portable-handoff` whenever losing context would slow the next session down. + +| Situation | How it helps | +| --- | --- | +| Switching from Codex to Claude, or Claude to Codex | Carries the project state through `.handoff/` instead of relying on copied chat text. | +| Hitting a context limit | Saves a compact continuation point before the current session gets too large. | +| Running multiple agents | Lets agent `C` resume from agent `A`, agent `B`, or both. | +| Pausing work overnight | Records what changed, what passed, what failed, and the next action. | +| Handing work to a teammate | Gives them a file-backed summary with decisions, blockers, and relevant files. | +| Reviewing long-running refactors | Keeps cleanup intent, touched files, and verification state visible. | +| Debugging across tools | Preserves hypotheses and failed paths so the next agent does not repeat them. | +| Working in constrained environments | Uses plain files and agent instructions; no service or package runtime is required. | + +## Quick Start + +Clone the repo, then install the skills into Codex: + +```bash +./install.sh codex +``` + +Install into Claude: + +```bash +./install.sh claude +``` + +Install into both: + +```bash +./install.sh both +``` + +Restart Codex or Claude after installing so the runtime reloads available +skills. + +## How The Workflow Feels + +At the end of a session, ask the source agent to save a handoff: + +```text +/handoff A +``` + +That writes: + +```text +.handoff/agents/A/snapshot.json +.handoff/agents/A/summary.md +``` + +In a new session, ask the receiving agent to resume: + +```text +/get-handoff A +``` + +For parallel or sequential work, merge multiple sources: + +```text +/get-handoff A,B +``` + +The receiving agent reads the named snapshots, chooses the newest snapshot as +the primary state, keeps older snapshots as supporting context, writes import +artifacts, and returns a compact resume brief. + +## What Gets Captured + +The `/handoff` skill tells the agent to capture: + +- current summary +- next action +- open tasks +- key decisions +- blockers +- files touched +- files to read first +- verification state +- confidence and uncertainty + +That is the information future agents usually need to continue the work without +guessing. + +## Storage Layout ```text .handoff/ @@ -21,6 +122,9 @@ Use `/handoff` to save the current live agent state under a named agent snapshot A/ snapshot.json summary.md + B/ + snapshot.json + summary.md imports/ current-get-handoff.json current-get-handoff.md @@ -29,29 +133,89 @@ Use `/handoff` to save the current live agent state under a named agent snapshot project-memory.json ``` -## Recommended Flow +The `.handoff/` directory belongs to the project being handed off. Commit it +only when you intentionally want handoff state in version control; otherwise +keep it local. -Source agent: +## Install Modes -```text -/handoff +By default, the installer symlinks this checkout's skill directories into the +target runtime. That is best while developing the skills because local edits are +reflected immediately. + +```bash +./install.sh both --mode symlink ``` -or: +Use copy mode when you want the installed runtime files to stay fixed even if +this checkout changes: -```text -/handoff A +```bash +./install.sh both --mode copy ``` -Receiving agent: +For tests or custom home directories: + +```bash +./install.sh codex --home /tmp/handoff-home --mode copy +``` + +For custom skill sources: + +```bash +./install.sh codex --source /path/to/skills --mode symlink +``` + +## What This Repo Ships ```text -/get-handoff A,B +install.sh +skills/ + handoff/ + SKILL.md + get-handoff/ + SKILL.md ``` -## Behavior +There is no Python package on `master`. The current product surface is the +skills plus a shell installer. + +The earlier Python package, tests, and internal planning docs are preserved on +the `python-package-docs` branch for future experimentation. + +## Design Principles + +- Tool-neutral: snapshots are plain files, not hidden runtime state. +- Agent-readable: the next agent can inspect every piece of transferred state. +- Small by default: the handoff is concise enough to paste, review, or merge. +- Honest about limits: hidden model activations are not portable; durable state + is. +- Useful with one agent, stronger with many: single-session resume, multi-agent + merge, and human review all use the same files. + +## Frequently Asked Questions + +### Is this a backup of the whole chat? + +No. It is a structured continuation brief. The goal is not to preserve every +token; the goal is to preserve the parts needed to continue correctly. + +### Does it require Codex and Claude to agree on an API? + +No. The skills tell agents how to read and write plain files. That keeps the +handoff independent of any one model or runtime. + +### Can I use it with only one runtime? + +Yes. It is useful even if you only use Codex or only use Claude. The main value +is making agent state explicit and resumable. + +### Can I merge multiple agents' work? + +Yes. `/get-handoff A,B` reads both snapshots, uses the newest snapshot for the +primary continuation state, and keeps older snapshots as supporting context. + +### Should `.handoff/` be committed? -- `.handoff/agents//` is the source of truth for per-agent snapshots -- `/get-handoff` merges named agent snapshots using newest-wins primary state -- older snapshots remain as supporting context -- hidden model state is not portable; only externalized state transfers +Usually no. Treat it like local session state unless your team intentionally +wants to share a handoff artifact through Git. diff --git a/docs/superpowers/plans/2026-04-09-generic-handoff-export-implementation.md b/docs/superpowers/plans/2026-04-09-generic-handoff-export-implementation.md deleted file mode 100644 index 13316dd..0000000 --- a/docs/superpowers/plans/2026-04-09-generic-handoff-export-implementation.md +++ /dev/null @@ -1,485 +0,0 @@ -# Generic Handoff Export Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor the current session-capture and `to-claude` implementation into a generic `$handoff` + `handoff export` flow that produces a structured LLM-readable handoff artifact while preserving the existing canonical `.handoff` model. - -**Architecture:** Reuse the current capture-capable `.handoff` session model, replace the Claude-specific export path with a generic export renderer that writes `.handoff/llm-handoff.md`, and keep the live capture contract generic so future model-specific skill implementations can target the same state and export surfaces. - -**Tech Stack:** Python 3.11+, `argparse`, `json`, `pathlib`, `textwrap`, `unittest`, existing stdlib-only `handoff` package - ---- - -## File Structure - -### Application files - -- Modify: `src/handoff/compiler.py` -- Modify: `src/handoff/checkpoint.py` -- Modify: `src/handoff/cli.py` -- Modify: `src/handoff/store.py` -- Modify: `src/handoff/capture.py` - -### Tests - -- Modify: `tests/test_cli.py` -- Modify: `tests/test_store.py` -- Modify: `tests/test_capture.py` - -### Documentation and helper scripts - -- Modify: `README.md` -- Modify: `scripts/handoff-to-claude.sh` -- Modify: `scripts/self-test.sh` - -## Implementation Notes - -- Keep `.handoff/` canonical. -- Do not remove the existing capture-capable state model; build the generic export on top of it. -- Prefer renaming/aliasing over parallel duplicate code paths. -- v1 should implement a generic export while still allowing the current Claude wrapper to consume it. -- Keep the initial live-capture implementation Codex-oriented only in provenance, not schema. - -### Task 1: Add Generic Export File Support to the Canonical Store - -**Files:** -- Modify: `src/handoff/store.py` -- Modify: `tests/test_store.py` - -- [ ] **Step 1: Write the failing canonical layout test for the generic export file** - -```python -class StoreExportLayoutTest(unittest.TestCase): - def test_ensure_layout_creates_llm_handoff_file(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - self.assertTrue((root / ".handoff" / "llm-handoff.md").exists()) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_store.StoreExportLayoutTest -v` - -Expected: FAIL because `.handoff/llm-handoff.md` does not exist yet - -- [ ] **Step 3: Add the generic export file to the canonical text-file set** - -```python -CANONICAL_TEXT_FILES = ( - "restore.md", - "llm-handoff.md", - "session/recent-summary.md", - "session/conversation-tail.md", - "session/next-action.md", - "session/status.md", - "session/capture-history.jsonl", - "plans/active-plan.md", - "verification/verification.md", - "memory/memory-merge-log.jsonl", -) -``` - -- [ ] **Step 4: Extend the store test’s expected file list** - -```python -expected_files = ( - "manifest.json", - "restore.md", - "llm-handoff.md", - ... -) -``` - -- [ ] **Step 5: Run the store tests to verify they pass** - -Run: `PYTHONPATH=src python -m unittest tests.test_store -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/store.py tests/test_store.py -git commit -m "Add generic llm handoff file to canonical layout" -``` - -### Task 2: Replace Claude-Specific Export Rendering With Generic LLM Handoff Rendering - -**Files:** -- Modify: `src/handoff/compiler.py` -- Modify: `src/handoff/checkpoint.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing generic export render test** - -```python -class GenericExportRenderTest(unittest.TestCase): - def test_export_writes_generic_llm_handoff_content(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Generic summary", - next_action="Generic next action", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - run_export(root) - export_text = (root / ".handoff" / "llm-handoff.md").read_text() - self.assertIn("# LLM Handoff", export_text) - self.assertIn("## Summary", export_text) - self.assertIn("## Constraints", export_text) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.GenericExportRenderTest -v` - -Expected: FAIL because `run_export` does not exist and no generic export file is written - -- [ ] **Step 3: Replace the Claude-oriented restore writer with a generic export renderer** - -```python -def compile_llm_handoff( - *, - summary: str, - next_action: str, - tasks: list[str], - decisions: list[str], - constraints: list[str], -) -> str: - ... -``` - -The output should be: - -```md -# LLM Handoff - -## Summary -... - -## Next Action -... - -## Open Tasks -- ... - -## Key Decisions -- ... - -## Constraints -- ... - -## Notes -- Use `.handoff/` as canonical state. -- Hidden model state is not portable. -``` - -- [ ] **Step 4: Add a generic export path in `checkpoint.py`** - -```python -def run_export(root: Path) -> str: - store = HandoffStore(root) - _refresh_portable_state(store, root, action="export") - return store.read_llm_handoff() -``` - -Also add: - -```python -store.write_llm_handoff(...) -store.read_llm_handoff() -``` - -if needed on the store surface. - -- [ ] **Step 5: Continue writing `restore.md` only as a compatibility artifact** - -Keep `restore.md` generation for backward compatibility if useful, but make `.handoff/llm-handoff.md` the canonical generic export file. - -- [ ] **Step 6: Run the generic export render test** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.GenericExportRenderTest -v` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/handoff/compiler.py src/handoff/checkpoint.py tests/test_cli.py -git commit -m "Render generic llm handoff exports" -``` - -### Task 3: Rename the CLI Surface From `to-claude` to `export` - -**Files:** -- Modify: `src/handoff/cli.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing CLI command test** - -```python -class ExportCommandTest(unittest.TestCase): - def test_export_command_prints_generic_handoff(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Summary", - next_action="Next action", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - output = main(["export", "--root", str(root)]) - self.assertEqual(output, 0) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.ExportCommandTest -v` - -Expected: FAIL because the `export` command does not exist yet - -- [ ] **Step 3: Add the generic export parser** - -```python -export = subparsers.add_parser("export") -export.add_argument("--root", type=Path, default=Path.cwd()) -``` - -- [ ] **Step 4: Route `export` through the generic export path** - -```python -if args.command == "export": - print(run_export(args.root), end="") - return 0 -``` - -- [ ] **Step 5: Keep `to-claude` temporarily as a compatibility alias** - -If you keep it at all in v1, it should call the same `run_export()` implementation so behavior does not diverge. - -- [ ] **Step 6: Run the CLI tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli -v` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/handoff/cli.py tests/test_cli.py -git commit -m "Add generic export command with alias compatibility" -``` - -### Task 4: Rename the Human-Facing Skill/Note Surface to Generic Handoff - -**Files:** -- Modify: `src/handoff/capture.py` -- Modify: `README.md` -- Modify: `tests/test_capture.py` - -- [ ] **Step 1: Write the failing note-surface expectation test** - -```python -class LiveCaptureNoteTest(unittest.TestCase): - def test_capture_writes_live_capture_note(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Summary", - next_action="Next action", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - note = (root / ".handoff" / "session" / "live-capture.md").read_text() - self.assertIn("# Live Capture", note) - self.assertIn("## Summary", note) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture.LiveCaptureNoteTest -v` - -Expected: FAIL because `live-capture.md` is not written yet - -- [ ] **Step 3: Extend `capture_session_state` to write `live-capture.md`** - -```python -note = f\"\"\"# Live Capture - -## Summary -{summary} - -## Next Action -{next_action} - -## Open Tasks -{...} - -## Key Decisions -{...} - -## Source -Captured from live session at {timestamp} -\"\"\" -``` - -- [ ] **Step 4: Update README to describe the generic flow** - -Replace Claude-first wording with: - -- generic `$handoff` skill contract -- generic `handoff export` -- `.handoff/llm-handoff.md` -- Codex live-capture implementation first - -- [ ] **Step 5: Run the capture tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/capture.py README.md tests/test_capture.py -git commit -m "Write generic live capture notes" -``` - -### Task 5: Update the Convenience Wrapper to Use Generic Export - -**Files:** -- Modify: `scripts/handoff-to-claude.sh` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing wrapper expectation as a shell smoke test** - -```bash -tmpdir=$(mktemp -d) -printf 'Summary\nNext action\n\n\n' | ./scripts/handoff-to-claude.sh "$tmpdir" > /tmp/out.txt -grep -q '# LLM Handoff' /tmp/out.txt -rm -rf "$tmpdir" /tmp/out.txt -``` - -- [ ] **Step 2: Run the wrapper manually to verify current behavior** - -Run: `./scripts/handoff-to-claude.sh "$(mktemp -d)"` - -Expected: It still uses the older target-specific path rather than the generic export language - -- [ ] **Step 3: Point the wrapper at `handoff export`** - -```bash -PYTHONPATH=src python -m handoff.cli export --root "$TARGET_ROOT" -``` - -- [ ] **Step 4: Update wrapper text to be target-neutral** - -It should no longer say “Paste this into Claude Code”. It should say something like: - -```text -Structured handoff exported for: - ... - -Use this handoff block as context in the destination model. -``` - -- [ ] **Step 5: Run the wrapper smoke test** - -Run: `chmod +x scripts/handoff-to-claude.sh && printf ... | ./scripts/handoff-to-claude.sh "$(mktemp -d)"` - -Expected: Prints the generic handoff block and writes `.handoff/llm-handoff.md` - -- [ ] **Step 6: Commit** - -```bash -git add scripts/handoff-to-claude.sh README.md -git commit -m "Make the wrapper use generic export output" -``` - -### Task 6: Final Integration and Regression Coverage - -**Files:** -- Modify: `tests/test_cli.py` -- Modify: `tests/test_capture.py` -- Modify: `tests/test_store.py` - -- [ ] **Step 1: Add an end-to-end capture-to-export regression** - -```python -class CaptureToExportIntegrationTest(unittest.TestCase): - def test_capture_then_export_uses_captured_context(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Captured from live session", - next_action="Continue elsewhere", - open_tasks=["Task 1"], - key_decisions=["Decision 1"], - ) - export_text = run_export(root) - self.assertIn("# LLM Handoff", export_text) - self.assertIn("Captured from live session", export_text) - self.assertIn("Continue elsewhere", export_text) -``` - -- [ ] **Step 2: Add a regression for canonical-task-only richness on generic export** - -```python - def test_export_uses_canonical_task_state_without_prompting(self) -> None: - ... -``` - -- [ ] **Step 3: Add a regression for `live-capture.md` creation** - -```python - def test_live_capture_note_is_written(self) -> None: - ... -``` - -- [ ] **Step 4: Run the full suite** - -Run: `PYTHONPATH=src python -m unittest discover -s tests -v` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_cli.py tests/test_capture.py tests/test_store.py -git commit -m "Lock generic handoff export with integration tests" -``` - -## Self-Review - -### Spec coverage - -- generic `$handoff` + generic export architecture: Tasks 2, 3, 5 -- Codex-first live capture, generic contract: Tasks 3 and 4 -- `.handoff/llm-handoff.md`: Tasks 1 and 2 -- structured export sections: Task 2 -- generic wrapper text: Task 5 -- final integration/regression coverage: Task 6 - -### Placeholder scan - -- No unresolved placeholder markers remain. -- Each task includes concrete files, commands, and code. - -### Type consistency - -- capture function: `capture_session_state` -- generic export path: `run_export` -- canonical export file: `.handoff/llm-handoff.md` -- generic skill surface: `$handoff` - diff --git a/docs/superpowers/plans/2026-04-09-portable-handoff-implementation.md b/docs/superpowers/plans/2026-04-09-portable-handoff-implementation.md deleted file mode 100644 index d82e889..0000000 --- a/docs/superpowers/plans/2026-04-09-portable-handoff-implementation.md +++ /dev/null @@ -1,954 +0,0 @@ -# Portable Handoff Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a stdlib-only Python CLI that maintains a canonical `.handoff/` state store, supports low-token checkpoint/resume flows, and optionally imports richer local state from OMX/OMC when available. - -**Architecture:** The implementation uses a tool-agnostic canonical store under `.handoff/` as the source of truth, a small adapter interface for raw and OMX importers, and a restore compiler that emits one compact `restore.md` landing file. Structured state sync is continuous and cheap; narrative summaries are compiled only on checkpoint/resume. - -**Tech Stack:** Python 3.11+, `argparse`, `dataclasses`, `json`, `pathlib`, `hashlib`, `textwrap`, `shutil`, `tempfile`, `unittest` - ---- - -## File Structure - -### Application files - -- Create: `pyproject.toml` -- Create: `src/handoff/__init__.py` -- Create: `src/handoff/cli.py` -- Create: `src/handoff/models.py` -- Create: `src/handoff/store.py` -- Create: `src/handoff/constraints.py` -- Create: `src/handoff/memory.py` -- Create: `src/handoff/compiler.py` -- Create: `src/handoff/checkpoint.py` -- Create: `src/handoff/adapters/__init__.py` -- Create: `src/handoff/adapters/base.py` -- Create: `src/handoff/adapters/raw.py` -- Create: `src/handoff/adapters/omx.py` - -### Tests - -- Create: `tests/test_cli.py` -- Create: `tests/test_store.py` -- Create: `tests/test_constraints.py` -- Create: `tests/test_memory.py` -- Create: `tests/test_compiler.py` -- Create: `tests/test_omx_adapter.py` -- Create: `tests/fixtures/omx/notepad.md` -- Create: `tests/fixtures/omx/project-memory.json` -- Create: `tests/fixtures/omx/plans/2026-04-08-sample-plan.md` -- Create: `tests/fixtures/omx/state/session.json` - -### Documentation - -- Modify: `README.md` - -## Implementation Notes - -- Use the Python standard library only in v1. Do not add third-party dependencies. -- Keep `.handoff/` canonical even when OMX import succeeds. -- Treat `AGENTS.md` and `CLAUDE.md` as equivalent instruction surfaces by alias, not duplication. -- Use `unittest` instead of `pytest` so the repository stays dependency-light. -- Prefer JSON for machine state and Markdown for user-facing restore and summary surfaces. - -### Task 1: Bootstrap the Python Package and CLI Skeleton - -**Files:** -- Create: `pyproject.toml` -- Create: `src/handoff/__init__.py` -- Create: `src/handoff/cli.py` -- Create: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing CLI smoke test** - -```python -import subprocess -import sys -import unittest -from pathlib import Path - - -class CLISmokeTest(unittest.TestCase): - def test_help_exits_zero(self) -> None: - repo = Path(__file__).resolve().parents[1] - result = subprocess.run( - [sys.executable, "-m", "handoff.cli", "--help"], - cwd=repo, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 0) - self.assertIn("checkpoint", result.stdout) - self.assertIn("resume", result.stdout) - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `python -m unittest tests.test_cli -v` - -Expected: FAIL with `ModuleNotFoundError: No module named 'handoff'` - -- [ ] **Step 3: Add packaging metadata** - -```toml -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "portable-handoff" -version = "0.1.0" -description = "Portable handoff state for cross-agent resume" -readme = "README.md" -requires-python = ">=3.11" - -[tool.setuptools] -package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] -``` - -- [ ] **Step 4: Add the package marker** - -```python -__all__ = ["__version__"] - -__version__ = "0.1.0" -``` - -- [ ] **Step 5: Add the CLI skeleton** - -```python -import argparse -from pathlib import Path - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="handoff") - subparsers = parser.add_subparsers(dest="command", required=True) - - checkpoint = subparsers.add_parser("checkpoint") - checkpoint.add_argument("--root", type=Path, default=Path.cwd()) - - resume = subparsers.add_parser("resume") - resume.add_argument("--root", type=Path, default=Path.cwd()) - - return parser - - -def main() -> int: - parser = build_parser() - parser.parse_args() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli -v` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add pyproject.toml src/handoff/__init__.py src/handoff/cli.py tests/test_cli.py -git commit -m "Bootstrap the portable handoff CLI" -``` - -### Task 2: Create the Canonical `.handoff/` Store and Manifest Writer - -**Files:** -- Create: `src/handoff/models.py` -- Create: `src/handoff/store.py` -- Create: `tests/test_store.py` - -- [ ] **Step 1: Write the failing store initialization test** - -```python -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.store import HandoffStore - - -class StoreInitTest(unittest.TestCase): - def test_init_creates_canonical_layout(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - self.assertTrue((root / ".handoff" / "manifest.json").exists()) - self.assertTrue((root / ".handoff" / "session" / "current.json").exists()) - self.assertTrue((root / ".handoff" / "tasks" / "tasks.json").exists()) - - manifest = json.loads((root / ".handoff" / "manifest.json").read_text()) - self.assertEqual(manifest["schema_version"], "1") -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_store.StoreInitTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.store` - -- [ ] **Step 3: Add canonical data models** - -```python -from dataclasses import asdict, dataclass, field -from typing import Any - - -@dataclass -class Manifest: - schema_version: str = "1" - active_adapter: str = "raw" - last_checkpoint_at: str | None = None - last_resume_at: str | None = None - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass -class SessionState: - goal: str = "" - status: str = "idle" - next_action: str = "" - active_mode: str | None = None - - def to_dict(self) -> dict[str, Any]: - return asdict(self) -``` - -- [ ] **Step 4: Implement the store layout and JSON writer** - -```python -import json -from pathlib import Path - -from handoff.models import Manifest, SessionState - - -class HandoffStore: - def __init__(self, root: Path) -> None: - self.root = root - self.base = root / ".handoff" - - def ensure_layout(self) -> None: - for relative in ( - "session", - "tasks", - "plans", - "memory", - "context", - "verification", - "artifacts/exports", - "artifacts/imports", - ): - (self.base / relative).mkdir(parents=True, exist_ok=True) - - self._write_json("manifest.json", Manifest().to_dict()) - self._write_json("session/current.json", SessionState().to_dict()) - self._write_json("tasks/tasks.json", {"tasks": []}) - self._write_json("plans/plan-index.json", {"active": None, "plans": []}) - self._write_json("memory/project-memory.json", {"entries": []}) - self._write_json("context/files-read.json", {"files": []}) - self._write_json("context/files-touched.json", {"files": []}) - self._write_json("context/constraints.json", {"sources": [], "rules": []}) - self._write_json("context/instruction-aliases.json", {"aliases": []}) - self._write_json("verification/checks.json", {"checks": []}) - - for relative in ( - "restore.md", - "session/recent-summary.md", - "session/conversation-tail.md", - "session/next-action.md", - "session/status.md", - "plans/active-plan.md", - "verification/verification.md", - ): - path = self.base / relative - path.touch(exist_ok=True) - - def _write_json(self, relative: str, payload: dict) -> None: - path = self.base / relative - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") -``` - -- [ ] **Step 5: Run the store test to verify it passes** - -Run: `PYTHONPATH=src python -m unittest tests.test_store.StoreInitTest -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/models.py src/handoff/store.py tests/test_store.py -git commit -m "Create canonical .handoff store layout" -``` - -### Task 3: Extract Instruction Constraints and Normalize `AGENTS.md` / `CLAUDE.md` - -**Files:** -- Create: `src/handoff/constraints.py` -- Create: `tests/test_constraints.py` - -- [ ] **Step 1: Write the failing constraints extraction test** - -```python -import tempfile -import unittest -from pathlib import Path - -from handoff.constraints import extract_constraints - - -class ConstraintsTest(unittest.TestCase): - def test_extracts_rules_and_aliases_instruction_files(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "AGENTS.md").write_text("- Do not add dependencies\n- Prefer tests first\n") - result = extract_constraints(root) - - self.assertIn("Do not add dependencies", result["rules"]) - self.assertEqual(result["aliases"][0]["canonical"], "AGENTS.md") - self.assertIn("CLAUDE.md", result["aliases"][0]["equivalents"]) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_constraints.ConstraintsTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.constraints` - -- [ ] **Step 3: Implement a minimal rule extractor** - -```python -from pathlib import Path - - -def extract_constraints(root: Path) -> dict: - rules: list[str] = [] - aliases = [ - { - "canonical": "AGENTS.md", - "equivalents": ["CLAUDE.md"], - } - ] - - for name in ("AGENTS.md", "CLAUDE.md"): - path = root / name - if not path.exists(): - continue - for line in path.read_text().splitlines(): - stripped = line.strip().lstrip("-").strip() - if stripped: - rules.append(stripped) - - deduped_rules = list(dict.fromkeys(rules)) - return {"sources": [str(root / "AGENTS.md"), str(root / "CLAUDE.md")], "rules": deduped_rules, "aliases": aliases} -``` - -- [ ] **Step 4: Add a test for non-instruction context extraction** - -```python - def test_other_context_files_are_recorded_as_path_plus_excerpt(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - notes = root / "docs" / "notes.md" - notes.parent.mkdir() - notes.write_text("# Notes\n- Preserve recent summary\n") - - result = extract_constraints(root) - self.assertIsInstance(result["rules"], list) -``` - -- [ ] **Step 5: Run the constraints tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_constraints -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/constraints.py tests/test_constraints.py -git commit -m "Extract normalized instruction constraints" -``` - -### Task 4: Implement Project Memory Merge + Dedup - -**Files:** -- Create: `src/handoff/memory.py` -- Create: `tests/test_memory.py` - -- [ ] **Step 1: Write the failing memory merge test** - -```python -import unittest - -from handoff.memory import merge_project_memory - - -class MemoryMergeTest(unittest.TestCase): - def test_merge_dedup_preserves_provenance(self) -> None: - current = { - "entries": [ - {"key": "convention:stdlib-only", "value": "Prefer stdlib only", "sources": ["local"], "updated_at": "2026-04-09T00:00:00Z"} - ] - } - incoming = { - "entries": [ - {"key": "convention:stdlib-only", "value": "Prefer stdlib only", "sources": ["omx"], "updated_at": "2026-04-09T01:00:00Z"}, - {"key": "architecture:canonical-store", "value": ".handoff is canonical", "sources": ["spec"], "updated_at": "2026-04-09T01:00:00Z"}, - ] - } - - merged, log = merge_project_memory(current, incoming) - self.assertEqual(len(merged["entries"]), 2) - self.assertIn("omx", merged["entries"][0]["sources"]) - self.assertGreaterEqual(len(log), 1) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_memory.MemoryMergeTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.memory` - -- [ ] **Step 3: Implement merge + dedup behavior** - -```python -from copy import deepcopy - - -def merge_project_memory(current: dict, incoming: dict) -> tuple[dict, list[dict]]: - merged = deepcopy(current) - entries = {entry["key"]: deepcopy(entry) for entry in merged.get("entries", [])} - merge_log: list[dict] = [] - - for entry in incoming.get("entries", []): - key = entry["key"] - if key not in entries: - entries[key] = deepcopy(entry) - merge_log.append({"action": "insert", "key": key}) - continue - - existing = entries[key] - existing_sources = list(dict.fromkeys(existing.get("sources", []) + entry.get("sources", []))) - existing["sources"] = existing_sources - if entry.get("updated_at", "") >= existing.get("updated_at", ""): - existing["value"] = entry["value"] - existing["updated_at"] = entry["updated_at"] - merge_log.append({"action": "merge", "key": key}) - - merged["entries"] = list(entries.values()) - return merged, merge_log -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `PYTHONPATH=src python -m unittest tests.test_memory.MemoryMergeTest -v` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/handoff/memory.py tests/test_memory.py -git commit -m "Add merge and dedup for project memory" -``` - -### Task 5: Build the Restore Compiler and Verification Surfaces - -**Files:** -- Create: `src/handoff/compiler.py` -- Create: `tests/test_compiler.py` - -- [ ] **Step 1: Write the failing restore compiler test** - -```python -import tempfile -import unittest -from pathlib import Path - -from handoff.compiler import compile_restore -from handoff.store import HandoffStore - - -class RestoreCompilerTest(unittest.TestCase): - def test_restore_contains_core_resume_sections(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - restore = compile_restore( - goal="Ship portable handoff v1", - status="Spec approved, plan written", - next_action="Implement canonical store first", - constraints=["Prefer stdlib only", "Keep .handoff canonical"], - tasks=["Bootstrap CLI", "Implement store"], - decisions=["Use optional OMX adapter"], - verification=["Spec reviewed manually"], - ) - - self.assertIn("Ship portable handoff v1", restore) - self.assertIn("Implement canonical store first", restore) - self.assertIn("Keep .handoff canonical", restore) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_compiler.RestoreCompilerTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.compiler` - -- [ ] **Step 3: Implement the compiler** - -```python -from textwrap import dedent - - -def compile_restore( - *, - goal: str, - status: str, - next_action: str, - constraints: list[str], - tasks: list[str], - decisions: list[str], - verification: list[str], -) -> str: - constraint_lines = "\n".join(f"- {item}" for item in constraints) or "- None" - task_lines = "\n".join(f"- {item}" for item in tasks) or "- None" - decision_lines = "\n".join(f"- {item}" for item in decisions) or "- None" - verification_lines = "\n".join(f"- {item}" for item in verification) or "- None" - - return dedent( - f"""\ - # Restore Brief - - ## Goal - {goal} - - ## Status - {status} - - ## Constraints - {constraint_lines} - - ## Open Tasks - {task_lines} - - ## Important Decisions - {decision_lines} - - ## Verification - {verification_lines} - - ## Exact Next Action - {next_action} - - ## Portability Boundary - - Durable state is portable through `.handoff/`. - - Hidden model state and opaque runtime state are not portable. - """ - ) -``` - -- [ ] **Step 4: Add a test that writes the compiler output to `restore.md`** - -```python - def test_restore_file_is_written(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - restore = compile_restore( - goal="Goal", - status="Status", - next_action="Next", - constraints=[], - tasks=[], - decisions=[], - verification=[], - ) - (root / ".handoff" / "restore.md").write_text(restore) - self.assertTrue((root / ".handoff" / "restore.md").read_text().startswith("# Restore Brief")) -``` - -- [ ] **Step 5: Run the compiler tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_compiler -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/compiler.py tests/test_compiler.py -git commit -m "Compile restore briefs from canonical state" -``` - -### Task 6: Implement Raw and OMX Adapters - -**Files:** -- Create: `src/handoff/adapters/__init__.py` -- Create: `src/handoff/adapters/base.py` -- Create: `src/handoff/adapters/raw.py` -- Create: `src/handoff/adapters/omx.py` -- Create: `tests/test_omx_adapter.py` -- Create: `tests/fixtures/omx/notepad.md` -- Create: `tests/fixtures/omx/project-memory.json` -- Create: `tests/fixtures/omx/plans/2026-04-08-sample-plan.md` -- Create: `tests/fixtures/omx/state/session.json` - -- [ ] **Step 1: Write the failing OMX adapter test** - -```python -import tempfile -import unittest -from pathlib import Path - -from handoff.adapters.omx import OMXAdapter - - -class OMXAdapterTest(unittest.TestCase): - def test_reads_notepad_plan_and_session(self) -> None: - fixture_root = Path(__file__).resolve().parent / "fixtures" / "omx" - adapter = OMXAdapter(fixture_root) - payload = adapter.capture() - - self.assertEqual(payload["adapter"], "omx") - self.assertIn("Working memory", payload["notes"]) - self.assertEqual(payload["session"]["cwd"], "/workspace/project") - self.assertEqual(len(payload["plans"]), 1) -``` - -- [ ] **Step 2: Add the OMX fixtures** - -```text -tests/fixtures/omx/notepad.md -## WORKING MEMORY -[2026-04-09T00:00:00Z] Working memory: build canonical .handoff store first. -``` - -```json -// tests/fixtures/omx/project-memory.json -{ - "entries": [ - { - "key": "architecture:handoff", - "value": ".handoff is canonical", - "sources": ["omx"], - "updated_at": "2026-04-09T00:00:00Z" - } - ] -} -``` - -```markdown - -# Sample Plan - -- Bootstrap canonical store -``` - -```json -// tests/fixtures/omx/state/session.json -{ - "cwd": "/workspace/project", - "session_id": "abc123" -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_omx_adapter.OMXAdapterTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.adapters.omx` - -- [ ] **Step 4: Implement the adapter interface** - -```python -from pathlib import Path -from typing import Protocol - - -class Adapter(Protocol): - name: str - - def available(self) -> bool: ... - - def capture(self) -> dict: ... - - -def read_text(path: Path) -> str: - return path.read_text() if path.exists() else "" -``` - -- [ ] **Step 5: Implement the raw adapter** - -```python -from pathlib import Path - - -class RawAdapter: - name = "raw" - - def __init__(self, root: Path) -> None: - self.root = root - - def available(self) -> bool: - return True - - def capture(self) -> dict: - return {"adapter": self.name, "root": str(self.root)} -``` - -- [ ] **Step 6: Implement the OMX adapter** - -```python -import json -from pathlib import Path - - -class OMXAdapter: - name = "omx" - - def __init__(self, root: Path) -> None: - self.root = root - - def available(self) -> bool: - return (self.root / "notepad.md").exists() - - def capture(self) -> dict: - plans = sorted((self.root / "plans").glob("*.md")) if (self.root / "plans").exists() else [] - session_path = self.root / "state" / "session.json" - session = json.loads(session_path.read_text()) if session_path.exists() else {} - return { - "adapter": self.name, - "notes": (self.root / "notepad.md").read_text() if (self.root / "notepad.md").exists() else "", - "plans": [path.read_text() for path in plans], - "session": session, - "project_memory": json.loads((self.root / "project-memory.json").read_text()) if (self.root / "project-memory.json").exists() else {"entries": []}, - } -``` - -- [ ] **Step 7: Run the adapter tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_omx_adapter -v` - -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add src/handoff/adapters/__init__.py src/handoff/adapters/base.py src/handoff/adapters/raw.py src/handoff/adapters/omx.py tests/test_omx_adapter.py tests/fixtures/omx -git commit -m "Add raw and OMX adapters" -``` - -### Task 7: Wire Checkpoint and Resume Commands End-to-End - -**Files:** -- Create: `src/handoff/checkpoint.py` -- Modify: `src/handoff/cli.py` -- Modify: `src/handoff/store.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing end-to-end checkpoint/resume test** - -```python -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -class CLIE2ETest(unittest.TestCase): - def test_checkpoint_then_resume_produces_restore_file(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - result = subprocess.run( - [sys.executable, "-m", "handoff.cli", "checkpoint", "--root", str(root)], - env={"PYTHONPATH": "src"}, - cwd=Path(__file__).resolve().parents[1], - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 0) - self.assertTrue((root / ".handoff" / "restore.md").exists()) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.CLIE2ETest -v` - -Expected: FAIL because `checkpoint` does not write `restore.md` - -- [ ] **Step 3: Implement checkpoint orchestration** - -```python -from pathlib import Path - -from handoff.compiler import compile_restore -from handoff.constraints import extract_constraints -from handoff.store import HandoffStore - - -def run_checkpoint(root: Path) -> None: - store = HandoffStore(root) - store.ensure_layout() - constraints = extract_constraints(root) - restore = compile_restore( - goal="", - status="checkpoint created", - next_action="Resume from restore.md", - constraints=constraints["rules"], - tasks=[], - decisions=[], - verification=[], - ) - (root / ".handoff" / "restore.md").write_text(restore) -``` - -- [ ] **Step 4: Implement resume orchestration** - -```python -from pathlib import Path - - -def run_resume(root: Path) -> str: - restore_path = root / ".handoff" / "restore.md" - if not restore_path.exists(): - raise FileNotFoundError("restore.md not found") - return restore_path.read_text() -``` - -- [ ] **Step 5: Wire the CLI commands** - -```python -from handoff.checkpoint import run_checkpoint, run_resume - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - if args.command == "checkpoint": - run_checkpoint(args.root) - print(args.root / ".handoff" / "restore.md") - return 0 - if args.command == "resume": - print(run_resume(args.root)) - return 0 - return 1 -``` - -- [ ] **Step 6: Run the CLI tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli -v` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add src/handoff/checkpoint.py src/handoff/cli.py src/handoff/store.py tests/test_cli.py -git commit -m "Wire checkpoint and resume workflows" -``` - -### Task 8: Document Usage and Add Final Regression Coverage - -**Files:** -- Modify: `README.md` -- Modify: `tests/test_cli.py` -- Modify: `tests/test_store.py` -- Modify: `tests/test_memory.py` - -- [ ] **Step 1: Add a regression test for merge+dedup during resume** - -```python - def test_resume_path_keeps_single_memory_entry_after_merge(self) -> None: - current = {"entries": [{"key": "a", "value": "x", "sources": ["local"], "updated_at": "1"}]} - incoming = {"entries": [{"key": "a", "value": "x", "sources": ["omx"], "updated_at": "2"}]} - merged, _ = merge_project_memory(current, incoming) - self.assertEqual(len(merged["entries"]), 1) - self.assertIn("omx", merged["entries"][0]["sources"]) -``` - -- [ ] **Step 2: Add README usage instructions** - -```markdown -# portable-handoff - -Portable handoff state for cross-agent resume. - -## Commands - -```bash -PYTHONPATH=src python -m handoff.cli checkpoint --root /path/to/repo -PYTHONPATH=src python -m handoff.cli resume --root /path/to/repo -``` - -## Behavior - -- `.handoff/` is canonical -- OMX state is imported only when available -- hidden model state is not portable -``` - -- [ ] **Step 3: Run the full test suite** - -Run: `PYTHONPATH=src python -m unittest discover -s tests -v` - -Expected: PASS with all tests green - -- [ ] **Step 4: Commit** - -```bash -git add README.md tests/test_cli.py tests/test_store.py tests/test_memory.py -git commit -m "Document portable handoff usage and lock behavior with tests" -``` - -## Self-Review - -### Spec coverage - -- Canonical `.handoff/` layout: covered by Task 2 -- Tool-agnostic core: covered by Tasks 1, 2, 5, and 7 -- OMX/OMC adapter in v1: covered by Task 6 -- Recent raw tail + summary strategy: covered by Tasks 2, 5, and 7 -- Merge + dedup project memory: covered by Task 4 -- Instruction aliasing for `AGENTS.md` / `CLAUDE.md`: covered by Task 3 -- Honest migration boundaries in restore output and docs: covered by Tasks 5 and 8 - -### Placeholder scan - -- No unresolved placeholder markers remain. -- Each test and implementation step includes concrete file paths, commands, and code. - -### Type consistency - -- CLI entry point is `handoff.cli` -- Canonical store class is `HandoffStore` -- Project memory merge function is `merge_project_memory` -- Restore compiler is `compile_restore` -- Adapter names and locations are consistent across tasks diff --git a/docs/superpowers/plans/2026-04-09-session-capture-to-claude-implementation.md b/docs/superpowers/plans/2026-04-09-session-capture-to-claude-implementation.md deleted file mode 100644 index f8f8d35..0000000 --- a/docs/superpowers/plans/2026-04-09-session-capture-to-claude-implementation.md +++ /dev/null @@ -1,621 +0,0 @@ -# Session Capture to Claude Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a session-aware capture flow plus a `to-claude` CLI command so Codex can externalize current chat context into `.handoff` and produce a paste-ready Claude Code prompt. - -**Architecture:** Extend the canonical `.handoff/session/current.json` state with captured fields, append live-capture events to `capture-history.jsonl`, and implement a `to-claude` command that either renders from rich existing state or interactively prompts for the missing summary/next-action payload. Keep the CLI and any future skill on the same canonical write path. - -**Tech Stack:** Python 3.11+, `argparse`, `json`, `pathlib`, `textwrap`, `unittest`, existing stdlib-only `handoff` package - ---- - -## File Structure - -### Application files - -- Modify: `src/handoff/models.py` -- Modify: `src/handoff/store.py` -- Modify: `src/handoff/compiler.py` -- Modify: `src/handoff/checkpoint.py` -- Modify: `src/handoff/cli.py` -- Create: `src/handoff/capture.py` - -### Tests - -- Modify: `tests/test_cli.py` -- Create: `tests/test_capture.py` - -### Documentation and helpers - -- Modify: `README.md` -- Modify: `scripts/handoff-to-claude.sh` - -## Implementation Notes - -- Keep `.handoff/` canonical; do not invent a parallel storage surface. -- Stay stdlib-only. -- Make `to-claude` usable without the live capture skill; the skill just improves fidelity. -- Capture should overwrite current state and append history. -- `restore.md` must render from normalized current state, not replay history directly. - -### Task 1: Extend Canonical Session State for Live Capture - -**Files:** -- Modify: `src/handoff/models.py` -- Modify: `src/handoff/store.py` -- Create: `tests/test_capture.py` - -- [ ] **Step 1: Write the failing capture-state initialization test** - -```python -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.store import HandoffStore - - -class CaptureStateTest(unittest.TestCase): - def test_ensure_layout_initializes_capture_fields_and_history_file(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - current = json.loads( - (root / ".handoff" / "session" / "current.json").read_text() - ) - self.assertIn("captured_summary", current) - self.assertIn("captured_open_tasks", current) - self.assertIn("captured_key_decisions", current) - self.assertTrue( - (root / ".handoff" / "session" / "capture-history.jsonl").exists() - ) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture.CaptureStateTest.test_ensure_layout_initializes_capture_fields_and_history_file -v` - -Expected: FAIL because the capture fields and history file do not exist yet - -- [ ] **Step 3: Extend `SessionState` with captured fields** - -```python -@dataclass -class SessionState: - goal: str = "" - status: str = "idle" - next_action: str = "" - active_mode: str | None = None - timestamp: str = "" - last_checkpoint_at: str | None = None - last_adapter_used: str = "raw" - captured_summary: str = "" - captured_open_tasks: list[str] = field(default_factory=list) - captured_key_decisions: list[str] = field(default_factory=list) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) -``` - -- [ ] **Step 4: Create the capture history file in the canonical layout** - -```python -CANONICAL_TEXT_FILES = ( - "restore.md", - "session/recent-summary.md", - "session/conversation-tail.md", - "session/next-action.md", - "session/status.md", - "session/capture-history.jsonl", - "plans/active-plan.md", - "verification/verification.md", - "memory/memory-merge-log.jsonl", -) -``` - -- [ ] **Step 5: Run the capture initialization test to verify it passes** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture.CaptureStateTest.test_ensure_layout_initializes_capture_fields_and_history_file -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/models.py src/handoff/store.py tests/test_capture.py -git commit -m "Extend canonical session state for live capture" -``` - -### Task 2: Implement Capture Persistence and History Appending - -**Files:** -- Create: `src/handoff/capture.py` -- Modify: `src/handoff/store.py` -- Modify: `tests/test_capture.py` - -- [ ] **Step 1: Write the failing capture write-path test** - -```python -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.capture import capture_session_state - - -class CaptureWriteTest(unittest.TestCase): - def test_capture_updates_current_state_and_appends_history(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="We finished the checkpoint/resume implementation and want Claude to continue the UX layer.", - next_action="Implement the to-claude command", - open_tasks=["Add interactive fallback", "Improve wrapper UX"], - key_decisions=["Use skill plus CLI", "Persist captured state"], - ) - - current = json.loads( - (root / ".handoff" / "session" / "current.json").read_text() - ) - self.assertEqual( - current["captured_summary"], - "We finished the checkpoint/resume implementation and want Claude to continue the UX layer.", - ) - self.assertEqual( - current["captured_open_tasks"], - ["Add interactive fallback", "Improve wrapper UX"], - ) - history = ( - root / ".handoff" / "session" / "capture-history.jsonl" - ).read_text() - self.assertIn("\"source\": \"codex-skill\"", history) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture.CaptureWriteTest -v` - -Expected: FAIL with `ModuleNotFoundError` for `handoff.capture` - -- [ ] **Step 3: Implement capture persistence** - -```python -import json -from pathlib import Path - -from handoff.store import HandoffStore - - -def capture_session_state( - *, - root: Path, - source: str, - summary: str, - next_action: str, - open_tasks: list[str], - key_decisions: list[str], -) -> None: - store = HandoffStore(root) - store.ensure_layout() - timestamp = store.timestamp() - - current = store.read_json("session/current.json", {}) - current["captured_summary"] = summary - current["captured_open_tasks"] = open_tasks - current["captured_key_decisions"] = key_decisions - current["next_action"] = next_action - current["timestamp"] = timestamp - store.write_json("session/current.json", current) - - store.append_jsonl( - "session/capture-history.jsonl", - [ - { - "timestamp": timestamp, - "source": source, - "summary": summary, - "next_action": next_action, - "open_tasks": open_tasks, - "key_decisions": key_decisions, - } - ], - ) -``` - -- [ ] **Step 4: Add a history append regression** - -```python - def test_capture_appends_history_without_replacing_prior_events(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="first", - next_action="first-action", - open_tasks=[], - key_decisions=[], - ) - capture_session_state( - root=root, - source="codex-skill", - summary="second", - next_action="second-action", - open_tasks=[], - key_decisions=[], - ) - lines = ( - root / ".handoff" / "session" / "capture-history.jsonl" - ).read_text().strip().splitlines() - self.assertEqual(len(lines), 2) -``` - -- [ ] **Step 5: Run the capture tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_capture -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/capture.py src/handoff/store.py tests/test_capture.py -git commit -m "Persist captured Codex session state" -``` - -### Task 3: Render Restore Output From Captured State - -**Files:** -- Modify: `src/handoff/checkpoint.py` -- Modify: `src/handoff/compiler.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing restore-priority test** - -```python -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.capture import capture_session_state -from handoff.checkpoint import run_checkpoint - - -class RestorePriorityTest(unittest.TestCase): - def test_checkpoint_prefers_captured_state_in_restore(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Captured summary", - next_action="Captured next action", - open_tasks=["Captured task"], - key_decisions=["Captured decision"], - ) - - run_checkpoint(root) - restore = (root / ".handoff" / "restore.md").read_text() - - self.assertIn("Captured summary", restore) - self.assertIn("Captured next action", restore) - self.assertIn("Captured task", restore) - self.assertIn("Captured decision", restore) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.RestorePriorityTest -v` - -Expected: FAIL because the current restore render path ignores captured fields - -- [ ] **Step 3: Feed captured fields into restore compilation** - -```python -restore = compile_restore( - goal=current_session.get("goal", "") or current_session.get("captured_summary", ""), - status=current_session.get("status") or f"{action} created", - next_action=current_session.get("next_action") or "Resume from restore.md", - constraints=constraints["rules"], - tasks=_dedupe( - current_session.get("captured_open_tasks", []) - + task_payload.get("tasks", []) - + imported_tasks - ), - decisions=_dedupe( - current_session.get("captured_key_decisions", []) - + _memory_values(merged_memory) - ), - verification=verification, -) -``` - -- [ ] **Step 4: Add a captured summary section to the restore compiler** - -```python -def compile_restore(..., captured_summary: str = "") -> str: - summary_block = f\"\"\"\n## Captured Summary\n{captured_summary}\n\"\"\" if captured_summary else \"\" - ... -``` - -- [ ] **Step 5: Run the restore-priority test to verify it passes** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.RestorePriorityTest -v` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/handoff/checkpoint.py src/handoff/compiler.py tests/test_cli.py -git commit -m "Render restore briefs from captured session state" -``` - -### Task 4: Add `to-claude` CLI With Interactive Fallback - -**Files:** -- Modify: `src/handoff/cli.py` -- Modify: `src/handoff/checkpoint.py` -- Modify: `tests/test_cli.py` - -- [ ] **Step 1: Write the failing `to-claude` command test** - -```python -import tempfile -import unittest -from pathlib import Path - -from handoff.cli import main - - -class ToClaudeCommandTest(unittest.TestCase): - def test_to_claude_prints_paste_ready_prompt_when_state_is_rich_enough(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Summary", - next_action="Next action", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - output = main(["to-claude", "--root", str(root)]) - self.assertEqual(output, 0) -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli.ToClaudeCommandTest -v` - -Expected: FAIL because `to-claude` does not exist yet - -- [ ] **Step 3: Add the `to-claude` parser** - -```python -to_claude = subparsers.add_parser("to-claude") -to_claude.add_argument("--root", type=Path, default=Path.cwd()) -``` - -- [ ] **Step 4: Implement `to-claude` with rich-state detection** - -```python -def run_to_claude(root: Path, *, input_fn=input) -> str: - store = HandoffStore(root) - _refresh_portable_state(store, root, action="resume") - current = store.read_json("session/current.json", {}) - memory = store.read_json("memory/project-memory.json", {"entries": []}) - - rich_enough = bool( - (current.get("goal") or current.get("captured_summary")) - and current.get("next_action") - and ( - current.get("captured_open_tasks") - or current.get("captured_key_decisions") - or memory.get("entries") - ) - ) - - if not rich_enough: - summary = input_fn("Summary: ").strip() - next_action = input_fn("Next action: ").strip() - open_tasks = input_fn("Open tasks (comma-separated, optional): ").strip() - key_decisions = input_fn("Key decisions (comma-separated, optional): ").strip() - capture_session_state( - root=root, - source="interactive-cli", - summary=summary, - next_action=next_action, - open_tasks=[item.strip() for item in open_tasks.split(",") if item.strip()], - key_decisions=[item.strip() for item in key_decisions.split(",") if item.strip()], - ) - _refresh_portable_state(store, root, action="resume") - - return ( - "Read .handoff/restore.md first.\\n" - "Then use .handoff/ as the source of truth for the current goal, status, tasks, memory, and next action.\\n" - "Refresh only the files you actually need after reading the restore brief.\\n" - "Continue from the recorded next action instead of rediscovering context.\\n" - ) -``` - -- [ ] **Step 5: Wire the command into `main()`** - -```python -if args.command == "to-claude": - print(run_to_claude(args.root), end="") - return 0 -``` - -- [ ] **Step 6: Add an interactive fallback test with mocked input** - -```python - def test_to_claude_prompts_when_state_is_too_sparse(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - prompts = iter(["Captured summary", "Captured next action", "Task A, Task B", "Decision A"]) - prompt = run_to_claude(root, input_fn=lambda _: next(prompts)) - self.assertIn("Read .handoff/restore.md first.", prompt) - current = json.loads((root / ".handoff" / "session" / "current.json").read_text()) - self.assertEqual(current["captured_summary"], "Captured summary") -``` - -- [ ] **Step 7: Run the CLI tests** - -Run: `PYTHONPATH=src python -m unittest tests.test_cli -v` - -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add src/handoff/cli.py src/handoff/checkpoint.py tests/test_cli.py -git commit -m "Add to-claude export with interactive fallback" -``` - -### Task 5: Update the Wrapper and Documentation - -**Files:** -- Modify: `scripts/handoff-to-claude.sh` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing wrapper expectation test as a shell assertion** - -```bash -tmpdir=$(mktemp -d) -output=$(./scripts/handoff-to-claude.sh "$tmpdir") -printf '%s' "$output" | grep -q 'Read .handoff/restore.md first.' -rm -rf "$tmpdir" -``` - -- [ ] **Step 2: Run the wrapper manually to verify current behavior** - -Run: `./scripts/handoff-to-claude.sh "$(mktemp -d)"` - -Expected: It still shells through `checkpoint` only and does not use `to-claude` - -- [ ] **Step 3: Update the wrapper to call `to-claude`** - -```bash -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TARGET_ROOT="${1:-$(pwd)}" - -cd "$ROOT_DIR" - -printf 'Portable handoff refreshed for:\n %s\n\n' "$TARGET_ROOT" -printf 'Paste this into Claude Code:\n\n' -PYTHONPATH=src python -m handoff.cli to-claude --root "$TARGET_ROOT" -``` - -- [ ] **Step 4: Update README to describe the new behavior** - -```markdown -## Codex To Claude Code Workflow - -Preferred path: - -1. Use the Codex capture skill while the live session still exists. -2. Run: - -```bash -PYTHONPATH=src python -m handoff.cli to-claude --root /path/to/repo -``` - -If the handoff state is too sparse, the command prompts for a short summary and saves it into `.handoff`. -``` - -- [ ] **Step 5: Run the wrapper smoke test** - -Run: `chmod +x scripts/handoff-to-claude.sh && ./scripts/handoff-to-claude.sh "$(mktemp -d)"` - -Expected: Prints the Claude prompt block, even when interactive fallback is needed - -- [ ] **Step 6: Commit** - -```bash -git add scripts/handoff-to-claude.sh README.md -git commit -m "Update Claude wrapper for live capture export" -``` - -### Task 6: Final Regression and Integration Coverage - -**Files:** -- Modify: `tests/test_capture.py` -- Modify: `tests/test_cli.py` -- Modify: `README.md` - -- [ ] **Step 1: Add an end-to-end regression that combines capture then `to-claude`** - -```python -class CaptureToClaudeIntegrationTest(unittest.TestCase): - def test_capture_then_to_claude_uses_captured_context(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_session_state( - root=root, - source="codex-skill", - summary="Captured from live Codex session", - next_action="Continue in Claude", - open_tasks=["Task 1"], - key_decisions=["Decision 1"], - ) - prompt = run_to_claude(root, input_fn=lambda _: self.fail("prompted unexpectedly")) - restore = (root / ".handoff" / "restore.md").read_text() - self.assertIn("Captured from live Codex session", restore) - self.assertIn("Continue in Claude", restore) - self.assertIn("Read .handoff/restore.md first.", prompt) -``` - -- [ ] **Step 2: Add a regression for capture history append semantics** - -```python - def test_capture_history_contains_both_events_after_multiple_captures(self) -> None: - ... -``` - -- [ ] **Step 3: Run the full suite** - -Run: `PYTHONPATH=src python -m unittest discover -s tests -v` - -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_capture.py tests/test_cli.py README.md -git commit -m "Lock live capture to Claude handoff with integration tests" -``` - -## Self-Review - -### Spec coverage - -- live capture into canonical state: Tasks 1 and 2 -- capture history append + current-state overwrite: Task 2 -- restore rendering from captured state: Task 3 -- `to-claude` CLI UX and interactive fallback: Task 4 -- wrapper and docs update: Task 5 -- integration/regression coverage: Task 6 - -### Placeholder scan - -- No unresolved placeholder markers remain. -- Each task includes concrete files, tests, commands, and code snippets. - -### Type consistency - -- canonical capture function: `capture_session_state` -- export command surface: `to-claude` -- persistent history file: `session/capture-history.jsonl` -- current-state captured keys: `captured_summary`, `captured_open_tasks`, `captured_key_decisions` diff --git a/docs/superpowers/plans/2026-04-12-agent-centric-skills-implementation.md b/docs/superpowers/plans/2026-04-12-agent-centric-skills-implementation.md deleted file mode 100644 index 21e669c..0000000 --- a/docs/superpowers/plans/2026-04-12-agent-centric-skills-implementation.md +++ /dev/null @@ -1,427 +0,0 @@ -# Agent-Centric Skills Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the CLI-first handoff product with two skill-first agent-centric workflows, `/handoff` and `/get-handoff`, backed by a canonical per-agent `.handoff/` state model. - -**Architecture:** Refactor the current session-centric store into an internal agent-centric library that reads and writes `.handoff/agents//` snapshots and compiles `.handoff/imports/current-get-handoff.*` artifacts. Remove public CLI commands, wrappers, and CLI-focused tests/docs. Ship two skill assets that become the only intended end-user surface. - -**Tech Stack:** Python 3.11 stdlib, existing `handoff` package modules, markdown skill assets, `unittest` - ---- - -### Task 1: Lock The New Public Surface And Cleanup Direction - -**Files:** -- Modify: `tests/test_skill.py` -- Create: `tests/test_agent_handoff_store.py` -- Create: `tests/test_get_handoff_merge.py` -- Delete: `tests/test_cli.py` -- Delete: `tests/test_packaging.py` -- Delete: `tests/test_feature_verifier.py` - -- [ ] **Step 1: Write the failing skill-surface test** - -Add a test that asserts the repo ships both skill assets and that they reference the new commands: - -```python -class SkillSurfaceTest(unittest.TestCase): - def test_repo_ships_handoff_and_get_handoff_skills(self) -> None: - repo = Path(__file__).resolve().parents[1] - handoff = repo / "skills" / "handoff" / "SKILL.md" - get_handoff = repo / "skills" / "get-handoff" / "SKILL.md" - - self.assertTrue(handoff.exists()) - self.assertTrue(get_handoff.exists()) - self.assertIn("/handoff", handoff.read_text()) - self.assertIn("/get-handoff", get_handoff.read_text()) -``` - -- [ ] **Step 2: Write the failing store-layout test** - -Add a test that expects agent-centric layout initialization: - -```python -class AgentStoreLayoutTest(unittest.TestCase): - def test_ensure_layout_creates_agent_and_import_roots(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - store = HandoffStore(Path(tmp)) - store.ensure_layout() - - self.assertTrue((Path(tmp) / ".handoff" / "agents").exists()) - self.assertTrue((Path(tmp) / ".handoff" / "imports").exists()) - self.assertTrue((Path(tmp) / ".handoff" / "shared").exists()) -``` - -- [ ] **Step 3: Write the failing merge test** - -Add a test that seeds two snapshots and expects newest-wins primary state: - -```python -class GetHandoffMergeTest(unittest.TestCase): - def test_newest_snapshot_wins_primary_fields(self) -> None: - merged = merge_snapshots([older_snapshot, newer_snapshot]) - - self.assertEqual(merged["primary_agent"], "B") - self.assertEqual(merged["summary"], "new summary") - self.assertIn("A", merged["sources"]) - self.assertIn("B", merged["sources"]) -``` - -- [ ] **Step 4: Run the focused tests to verify RED** - -Run: - -```bash -python -m unittest \ - tests.test_skill \ - tests.test_agent_handoff_store \ - tests.test_get_handoff_merge -v -``` - -Expected: FAIL because the repo still ships the CLI-first surface and the agent-centric store/merge APIs do not exist yet. - -### Task 2: Refactor The Canonical Store To Agent-Centric State - -**Files:** -- Modify: `src/handoff/store.py` -- Modify: `src/handoff/models.py` -- Modify: `src/handoff/capture.py` -- Create: `src/handoff/merge.py` -- Test: `tests/test_agent_handoff_store.py` -- Test: `tests/test_get_handoff_merge.py` - -- [ ] **Step 1: Replace the session-centric canonical layout** - -Update the store constants so the canonical layout is: - -```python -CANONICAL_DIRECTORIES = ( - "agents", - "imports", - "shared", -) - -CANONICAL_JSON_FILES = ( - "shared/constraints.json", - "shared/project-memory.json", -) - -CANONICAL_TEXT_FILES = () -``` - -- [ ] **Step 2: Add explicit agent snapshot helpers** - -Implement store methods with this shape: - -```python -def write_agent_snapshot(self, agent: str, payload: dict) -> Path: ... -def read_agent_snapshot(self, agent: str) -> dict: ... -def write_agent_summary(self, agent: str, content: str) -> Path: ... -def write_import_artifacts(self, payload: dict, content: str) -> None: ... -``` - -- [ ] **Step 3: Define the new snapshot model** - -Add or update model helpers for: - -```python -{ - "agent": "A", - "timestamp": "...", - "runtime": "codex", - "summary": "...", - "next_action": "...", - "open_tasks": [], - "key_decisions": [], - "blockers": [], - "files_touched": [], - "files_read_first": [], - "verification": [], - "confidence": "medium", - "uncertainties": [], - "provenance": {"source": "handoff-skill"} -} -``` - -- [ ] **Step 4: Implement newest-wins merge** - -Create `src/handoff/merge.py` with a minimal merge function: - -```python -def merge_snapshots(snapshots: list[dict]) -> dict: - ordered = sorted(snapshots, key=lambda item: item["timestamp"], reverse=True) - primary = ordered[0] - return { - "primary_agent": primary["agent"], - "summary": primary["summary"], - "next_action": primary["next_action"], - "sources": [item["agent"] for item in ordered], - "snapshots": ordered, - } -``` - -- [ ] **Step 5: Run focused tests to verify GREEN** - -Run: - -```bash -python -m unittest \ - tests.test_agent_handoff_store \ - tests.test_get_handoff_merge -v -``` - -Expected: PASS - -### Task 3: Implement The Internal Handoff And Get-Handoff Compilers - -**Files:** -- Modify: `src/handoff/compiler.py` -- Modify: `src/handoff/capture.py` -- Create: `tests/test_handoff_rendering.py` - -- [ ] **Step 1: Write the failing rendering test** - -Add a test that expects one per-agent summary and one merged import brief: - -```python -class HandoffRenderingTest(unittest.TestCase): - def test_compile_get_handoff_render_contains_primary_and_appendix(self) -> None: - text = compile_get_handoff_markdown(merged_payload) - - self.assertIn("# Get Handoff", text) - self.assertIn("## Primary Context", text) - self.assertIn("## Additional Agent Snapshots", text) -``` - -- [ ] **Step 2: Implement per-agent summary rendering** - -Add a function similar to: - -```python -def compile_agent_summary(snapshot: dict) -> str: - return ( - f"# Agent Handoff: {snapshot['agent']}\n\n" - f"## Summary\n{snapshot['summary']}\n\n" - f"## Next Action\n{snapshot['next_action']}\n" - ) -``` - -- [ ] **Step 3: Implement merged import rendering** - -Add a function similar to: - -```python -def compile_get_handoff_markdown(payload: dict) -> str: - primary = payload["snapshots"][0] - return ( - "# Get Handoff\n\n" - f"## Primary Context\nAgent: {primary['agent']}\n\n" - f"## Summary\n{primary['summary']}\n\n" - "## Additional Agent Snapshots\n" - ) -``` - -- [ ] **Step 4: Run rendering tests** - -Run: - -```bash -python -m unittest tests.test_handoff_rendering -v -``` - -Expected: PASS - -### Task 4: Replace The Product Surface With Two Skills - -**Files:** -- Modify: `skills/handoff/SKILL.md` -- Create: `skills/get-handoff/SKILL.md` -- Modify: `src/handoff/assets/handoff/SKILL.md` -- Create: `src/handoff/assets/get-handoff/SKILL.md` -- Test: `tests/test_skill.py` - -- [ ] **Step 1: Rewrite the `/handoff` skill around direct snapshot capture** - -Replace CLI instructions with a skill contract that: - -- resolves the agent name from explicit arg or current session identity -- summarizes the live session into snapshot fields -- writes `.handoff/agents//snapshot.json` -- writes `.handoff/agents//summary.md` -- prints `handoff saved for agent: ` - -- [ ] **Step 2: Create the `/get-handoff` skill** - -Create a skill that: - -- accepts explicit source agent names -- reads `.handoff/agents//snapshot.json` for each source -- merges by newest timestamp -- writes `.handoff/imports/current-get-handoff.json` -- writes `.handoff/imports/current-get-handoff.md` -- returns the merged context - -- [ ] **Step 3: Mirror the skill assets under `src/handoff/assets/`** - -Ensure packaged assets match repo-local skill docs exactly. - -- [ ] **Step 4: Run skill tests** - -Run: - -```bash -python -m unittest tests.test_skill -v -``` - -Expected: PASS - -### Task 5: Remove The Public CLI Surface And Obsolete Tooling - -**Files:** -- Delete: `src/handoff/cli.py` -- Delete: `src/handoff/install.py` -- Delete: `scripts/handoff-to-claude.sh` -- Delete: `scripts/self-test.sh` -- Delete: `scripts/verify-features.sh` -- Modify: `pyproject.toml` -- Modify: `README.md` - -- [ ] **Step 1: Remove the console script entrypoint** - -Delete the script entrypoint from `pyproject.toml`: - -```toml -[project.scripts] -handoff = "handoff.cli:main" -``` - -- [ ] **Step 2: Remove CLI-only modules and wrappers** - -Delete: - -```text -src/handoff/cli.py -src/handoff/install.py -scripts/handoff-to-claude.sh -scripts/self-test.sh -scripts/verify-features.sh -``` - -- [ ] **Step 3: Rewrite README around the two skills only** - -The new top-level README should present only this UX: - -```md -## Main Commands - -- `/handoff [agent?]` -- `/get-handoff A,B` -``` - -- [ ] **Step 4: Verify the cleanup with a grep-based regression** - -Run: - -```bash -rg -n "handoff export|handoff capture|install-skill|to-claude|python -m handoff.cli" README.md skills src tests -``` - -Expected: no product-surface references remain outside historical docs. - -### Task 6: Add End-To-End Agent Handoff Integration Tests - -**Files:** -- Create: `tests/test_agent_handoff_integration.py` -- Modify: `tests/test_capture.py` -- Modify: `tests/test_store.py` - -- [ ] **Step 1: Write the `A -> C` integration test** - -Add a test that writes a snapshot for `A`, then reads it through the import path: - -```python -class AgentHandoffIntegrationTest(unittest.TestCase): - def test_single_agent_get_handoff_uses_that_agent_as_primary(self) -> None: - store.write_agent_snapshot("A", snapshot_a) - merged = merge_snapshots([store.read_agent_snapshot("A")]) - - self.assertEqual(merged["primary_agent"], "A") -``` - -- [ ] **Step 2: Write the `A + B -> C` integration test** - -Add a test that writes `A` and `B` snapshots and expects `B` to win when newer: - -```python -def test_multi_agent_get_handoff_uses_newest_snapshot_as_primary(self) -> None: - store.write_agent_snapshot("A", older) - store.write_agent_snapshot("B", newer) - merged = merge_snapshots([store.read_agent_snapshot("A"), store.read_agent_snapshot("B")]) - - self.assertEqual(merged["primary_agent"], "B") -``` - -- [ ] **Step 3: Run the integration tests** - -Run: - -```bash -python -m unittest tests.test_agent_handoff_integration -v -``` - -Expected: PASS - -### Task 7: Run The Full Verification Pass - -**Files:** -- Modify: `README.md` -- Modify: `tests/*` as needed - -- [ ] **Step 1: Run the full test suite** - -Run: - -```bash -python -m unittest discover -s tests -v -``` - -Expected: PASS - -- [ ] **Step 2: Run the cleanup grep** - -Run: - -```bash -rg -n "handoff export|handoff capture|install-skill|to-claude|python -m handoff.cli" README.md skills src tests -``` - -Expected: no matches outside archived planning/spec documents. - -- [ ] **Step 3: Record the final product boundary** - -Before finishing, confirm the shipped UX is only: - -- `/handoff [agent?]` -- `/get-handoff A,B` - -Anything else should be either deleted or clearly marked as internal/non-product code. - -## Spec Coverage Check - -- Agent-centric storage: Task 2 -- `/handoff` skill with resolved agent name: Task 4 -- `/get-handoff A,B` merge/import path: Tasks 2, 3, 4, 6 -- Newest-wins semantics: Tasks 2 and 6 -- CLI removal and cleanup: Task 5 -- Skill-first docs/tests: Tasks 1, 4, 5, 7 - -## Placeholder Scan - -No placeholder behavior is left unspecified: - -- store layout is explicit -- snapshot fields are explicit -- merge rule is explicit -- cleanup targets are explicit -- verification commands are explicit diff --git a/docs/superpowers/specs/2026-04-09-generic-handoff-export-design.md b/docs/superpowers/specs/2026-04-09-generic-handoff-export-design.md deleted file mode 100644 index 9ad7c47..0000000 --- a/docs/superpowers/specs/2026-04-09-generic-handoff-export-design.md +++ /dev/null @@ -1,461 +0,0 @@ -# Generic Handoff Export Design - -## Purpose - -Design a generic handoff flow that captures live session context into the canonical `.handoff/` state and exports a structured LLM-readable handoff block that any downstream model can consume. - -The system must not be Claude-specific or Codex-specific at the architectural level. It should support one-to-many handoff: the source model and the destination model may vary across Codex, Claude, Kimi, Grok, Copilot, or future tools. - -## Problem Statement - -The current implementation already supports: - -- canonical `.handoff/` state -- optional `.omx/` imports -- checkpoint/resume refresh -- restore rendering -- a `to-claude` path that prints a Claude-oriented prompt - -That is useful, but the product boundary is too target-specific. A true handoff system should not treat Claude as the defining export format, because the same structured context should be exportable to multiple downstream models. - -The missing abstraction is: - -- a generic live-capture surface -- a generic export command -- a generic structured handoff file for LLM consumption - -## Goals - -- Make the handoff architecture target-agnostic -- Keep `.handoff/` canonical -- Support one live-capture skill implementation in Codex first -- Export a generic structured LLM-readable handoff artifact -- Keep the user flow minimal and CLI-first -- Preserve honest limits around hidden model state - -## Non-Goals - -- Automatic shared runtime memory between tools -- Perfect session cloning across models -- Direct integration with every target model in v1 -- Packaging/distribution work in this design pass - -## User Promise - -This feature provides a high-fidelity reconstructed handoff by: - -- capturing live session context when available -- merging it into canonical `.handoff` state -- exporting a structured, model-readable handoff document - -The export is generic. Specific models consume it as input, but the handoff data model is not owned by any one model vendor. - -## Chosen Approach - -Use a **generic core + one concrete live-capture implementation**: - -- generic `$handoff` skill contract -- generic `handoff export` CLI command -- generic structured export file: `.handoff/llm-handoff.md` -- Codex live-capture skill implementation first - -This keeps the architecture correct while limiting initial implementation scope. - -## Product Surface - -### Skill surface - -The skill should be named: - -- `$handoff` - -This name is generic and should remain valid regardless of source or destination model. - -### CLI surface - -The export command should be: - -```bash -PYTHONPATH=src python -m handoff.cli export --root /path/to/repo -``` - -In the future, packaging may make this available as: - -```bash -handoff export --root /path/to/repo -``` - -### Export file - -The canonical exported file should be: - -```text -.handoff/llm-handoff.md -``` - -The CLI should: - -- write the file -- print the same content to stdout - -## Live Capture Scope - -The initial live-capture implementation is Codex-only, but the contract must be generic. - -That means: - -- the skill behavior and state schema should not mention Codex-specific concepts except in provenance -- future Claude/Kimi/Grok/Copilot skill implementations should be able to write into the same format - -## Capture Data Model - -The feature builds on the existing capture-capable session model. - -### Canonical files - -```text -.handoff/ - session/ - current.json - capture-history.jsonl - live-capture.md - conversation-tail.md # optional - llm-handoff.md -``` - -### Captured payload - -Default live capture payload: - -- summary -- next action -- open tasks -- key decisions - -Optional with `--include-tail`: - -- short recent conversation tail - -### Current session shape - -`session/current.json` should continue to hold the latest captured state: - -```json -{ - "goal": "...", - "status": "...", - "next_action": "...", - "active_mode": null, - "timestamp": "...", - "last_checkpoint_at": "...", - "last_adapter_used": "raw", - "captured_summary": "...", - "captured_open_tasks": ["..."], - "captured_key_decisions": ["..."] -} -``` - -### Capture history shape - -`session/capture-history.jsonl` should append one event per capture: - -```json -{ - "timestamp": "...", - "source": "codex-skill", - "summary": "...", - "next_action": "...", - "open_tasks": ["..."], - "key_decisions": ["..."] -} -``` - -The `source` field is provenance, not a schema specialization. - -## Human-Readable Note - -The live-capture flow should also write: - -```text -.handoff/session/live-capture.md -``` - -Suggested content: - -```md -# Live Capture - -## Summary -... - -## Next Action -... - -## Open Tasks -- ... - -## Key Decisions -- ... - -## Source -Captured from live session at ... -``` - -This file is for human inspection. It is not the source of truth. - -## Export File Shape - -The generic exported file should be: - -```text -.handoff/llm-handoff.md -``` - -It should be structured, readable by humans, and easy for downstream LLMs to follow. - -### Default exported sections - -The default export should include: - -1. Summary -2. Next Action -3. Open Tasks -4. Key Decisions -5. Constraints - -This matches the approved default export payload. - -### Suggested shape - -```md -# LLM Handoff - -## Summary -... - -## Next Action -... - -## Open Tasks -- ... - -## Key Decisions -- ... - -## Constraints -- ... - -## Notes -- Use `.handoff/` as canonical state. -- Hidden model state is not portable. -``` - -This is intentionally generic. Claude can read it. Codex can read it. Future targets can also read it. - -## Render Rules - -The export should be rendered from normalized current state, not from replaying history. - -### Render priority - -1. `session/current.json` - - `goal` - - `status` - - `next_action` - - `captured_summary` - - `captured_open_tasks` - - `captured_key_decisions` - -2. canonical supporting state - - `tasks/tasks.json` - - `memory/project-memory.json` - - `context/constraints.json` - -3. optional adapter-derived content - - imported OMX context - -### Merge rules - -When exporting: - -- next action comes from current state -- open tasks merge captured tasks and canonical tasks with dedup -- key decisions merge captured decisions and memory-derived decisions with dedup -- constraints come from canonical extracted constraints -- capture history is not rendered directly - -## `handoff export` CLI UX - -### Command - -```bash -PYTHONPATH=src python -m handoff.cli export --root /path/to/repo -``` - -### Behavior - -`handoff export` should: - -1. refresh canonical state -2. determine whether the handoff state is rich enough -3. if rich enough: - - render `.handoff/llm-handoff.md` - - print it to stdout -4. if too sparse: - - prompt interactively for a short summary - - prompt for next action - - optionally prompt for open tasks and key decisions - - persist those values into canonical state - - regenerate the export file - - print it to stdout - -### Richness heuristic - -The state is rich enough if: - -- `goal` or `captured_summary` is non-empty -- `next_action` is non-empty -- and at least one of these exists: - - captured open tasks - - captured key decisions - - canonical tasks - - project memory entries - - imported adapter context - -If not, interactive prompting is required. - -### Interactive fallback - -Required fields: - -- summary -- next action - -Optional fields: - -- open tasks -- key decisions - -Prompted values must be saved into canonical state, not used only transiently. - -## Skill Responsibilities - -The `$handoff` skill should: - -- read the live session context -- summarize it into: - - summary - - next action - - open tasks - - key decisions -- update `session/current.json` -- append `capture-history.jsonl` -- write `live-capture.md` -- optionally write `conversation-tail.md` when `--include-tail` is requested -- refresh canonical state -- write `.handoff/llm-handoff.md` -- print the exported handoff text - -This should be implemented first for Codex, but the contract must remain generic. - -## Optional `--include-tail` - -The live-capture skill should support: - -- `--include-tail` - -Default: - -- do not capture raw conversation tail - -With flag: - -- write a short recent conversation tail into `.handoff/session/conversation-tail.md` - -This is off by default to keep token and noise costs down. - -## What This Improves - -With the generic capture/export flow: - -- live session context can be externalized before the session ends -- the export becomes reusable across multiple target models -- the system stops treating Claude as the defining output contract -- the handoff becomes much closer to “capture this conversation for another model” - -## What Still Cannot Be Transferred Perfectly - -Even with live capture: - -- hidden reasoning remains non-portable -- exact context-window weighting remains non-portable -- full transcript semantics are not transferred by default -- target models still reconstruct from saved artifacts - -The honest product promise remains: - -- **high-fidelity reconstructed handoff** - -not: - -- **exact session migration** - -## Risks and Mitigations - -### Risk: architecture stays implicitly Claude-shaped - -Mitigation: - -- use generic names: - - `$handoff` - - `handoff export` - - `.handoff/llm-handoff.md` - -### Risk: Codex-specific capture leaks into the schema - -Mitigation: - -- keep source model only as provenance -- keep payload schema model-neutral - -### Risk: sparse state still produces weak exports - -Mitigation: - -- use the richness heuristic -- prompt interactively when necessary - -### Risk: note file becomes the actual state source - -Mitigation: - -- keep `live-capture.md` human-facing only -- render from canonical structured state - -## Recommended v1 Scope - -Implement: - -- generic export command -- generic export file -- generic `$handoff` skill contract -- Codex live-capture implementation first -- interactive fallback -- `live-capture.md` -- optional `--include-tail` - -Exclude from v1: - -- multiple concrete target renderers -- packaging/distribution -- automatic Claude/Copilot-specific integration -- transcript-tail capture by default - -## ADR - -- **Decision:** Reframe the feature as generic capture plus generic structured export, with one concrete live-capture implementation in Codex first. -- **Drivers:** one-to-many handoff architecture, target independence, reusable file format, honest long-term design. -- **Alternatives considered:** Claude-specific prompt export; generic text only with no live skill; multi-target implementation in v1. -- **Why chosen:** best balance of correct architecture and practical delivery scope. -- **Consequences:** the current Claude-specific language should eventually be reduced in favor of generic export naming; future targets can layer on top without schema change. -- **Follow-ups:** update the implementation plan to replace `to-claude` with generic export, decide whether to rename the existing CLI now or introduce aliases first, and define the Codex skill implementation details. diff --git a/docs/superpowers/specs/2026-04-09-portable-handoff-design.md b/docs/superpowers/specs/2026-04-09-portable-handoff-design.md deleted file mode 100644 index e84a166..0000000 --- a/docs/superpowers/specs/2026-04-09-portable-handoff-design.md +++ /dev/null @@ -1,552 +0,0 @@ -# Portable Handoff Design - -## Purpose - -Design a tool-agnostic handoff system that lets one coding agent session stop and another resume with high-fidelity project continuity across Claude Code, Codex, and similar tools. - -The system is intended to preserve durable project context with low token overhead. It must be usable without OMX/OMC, while taking advantage of OMX/OMC if available. - -## Problem Statement - -Current agent sessions hold important context in a mix of: - -- chat history -- hidden model state -- local files -- runtime-specific session state -- repo metadata - -This makes cross-tool resume unreliable. A user can often recover files and plans, but not the current working state, recent decisions, verification status, or the exact next step. The result is token waste, repeated discovery, and inconsistent execution after migration. - -## Goals - -- Preserve durable session context across tools with low ongoing token cost. -- Make the system tool-agnostic by default. -- Support richer import when OMX/OMC is installed. -- Distinguish clearly between state that can be migrated with high fidelity and state that cannot. -- Keep the source of truth on disk in a canonical, inspectable format. -- Support explicit checkpoint/restore and low-cost always-on structured sync. - -## Non-Goals - -- Perfect cloning of hidden model state. -- Bit-for-bit recreation of a prior context window. -- Dependence on any one runtime or plugin ecosystem. -- Continuous full-transcript summarization. -- Replacing git, issue trackers, or formal documentation systems. - -## User Promise - -This system migrates durable project state, structured execution context, project memory, and recent conversational context across tools. - -It does not clone opaque internal model state, hidden reasoning state, or tool-private runtime context that was never externalized. - -If richer local tooling is available, adapters improve fidelity. Otherwise the system falls back to canonical raw state stored in the repository. - -## Migration Guarantees - -### High-fidelity portable state - -These can be migrated nearly exactly if written into the handoff system: - -- current goal -- current status -- exact next action -- task list -- active plan -- decisions and rationale -- verification state -- files read -- files touched -- repo metadata -- extracted constraints from instruction files -- project memory entries -- recent conversation summary -- short raw conversation tail - -### Reconstructed state - -These can be reconstructed, but not perfectly preserved: - -- “what mattered most” in the prior context window -- ordering or emphasis inferred from summaries -- current mode inferred from adapter state -- active file priority inferred from recent reads/touches - -### Non-portable state - -These cannot be migrated perfectly: - -- hidden model reasoning state -- exact context-window weighting and salience -- tool-runtime-private state that was never externalized -- private subagent cognition unless explicitly saved -- exact pause/resume semantics at the internal model level - -## Design Principles - -1. Canonical files over hidden memory -2. Tool-agnostic core, optional adapters -3. Low steady-state token cost -4. Explicit provenance for imported state -5. Replace session-local state, merge stable project memory -6. Recompute constraints from live files when possible -7. Keep restore surfaces compact and human-auditable - -## Chosen Architecture - -Use a tool-agnostic canonical handoff store under `.handoff/`, plus optional adapters. - -### Core model - -The canonical model is split into: - -- session state -- execution state -- project memory -- instruction constraints -- provenance - -### Runtime model - -The runtime uses: - -- always-on structured sync for small, durable state -- explicit checkpoint bundles for cross-tool migration -- on-demand restore packet generation - -### Adapter model - -Adapters are optional import/export helpers. They enrich fidelity but never become the source of truth. - -v1 includes: - -- raw filesystem adapter -- OMX/OMC adapter - -Future adapters may include: - -- Claude Code-specific metadata import -- Codex-specific metadata import -- external issue/PR/task systems - -## Canonical Storage Layout - -The canonical root is: - -```text -.handoff/ - restore.md - manifest.json - session/ - current.json - recent-summary.md - conversation-tail.md - next-action.md - status.md - tasks/ - tasks.json - plans/ - active-plan.md - plan-index.json - memory/ - project-memory.json - memory-merge-log.jsonl - context/ - files-read.json - files-touched.json - constraints.json - instruction-aliases.json - verification/ - verification.md - checks.json - artifacts/ - exports/ - imports/ -``` - -## File Semantics - -### `restore.md` - -The single cross-tool landing file. A new tool should read this first. - -It should contain: - -- current goal -- current status -- active constraints -- active plan -- open tasks -- important decisions -- files to read first -- verification state -- exact next action -- portability guarantees and limits - -### `manifest.json` - -Schema versioning, timestamps, active adapter information, and integrity metadata. - -### `session/current.json` - -Current session-local state: - -- goal -- status -- active mode if known -- timestamp -- last checkpoint time -- last adapter used - -### `session/recent-summary.md` - -Compact semantic summary of recent work, intended to be cheap to read and regenerate. - -### `session/conversation-tail.md` - -Short raw tail of recent conversation plus minimal annotations. This exists for nuance continuity, not as a full transcript archive. - -### `session/next-action.md` - -One exact next step for the next tool or operator. - -### `tasks/tasks.json` - -Canonical task list and status tracking. This should be the structured source for open/completed/in-progress tasks. - -### `plans/active-plan.md` - -The active plan or a normalized extract of the active plan. The underlying source plan may still live elsewhere. - -### `plans/plan-index.json` - -References to known plan/spec artifacts and which one is active. - -### `memory/project-memory.json` - -Long-term reusable project memory: - -- architecture notes -- conventions -- stable gotchas -- repeated decisions worth preserving -- durable environment facts - -### `memory/memory-merge-log.jsonl` - -Append-only log of imports, dedup decisions, and provenance changes. - -### `context/files-read.json` - -Ordered recent files read or inspected, with timestamps and importance hints. - -### `context/files-touched.json` - -Ordered recent files changed or intended to change. - -### `context/constraints.json` - -Extracted normalized constraints from instruction surfaces, not raw file dumps. - -### `context/instruction-aliases.json` - -Maps equivalent instruction surfaces, such as: - -- `AGENTS.md` -- `CLAUDE.md` - -If one should stand in for another, the system may use symlinks or alias metadata rather than duplicating content. - -### `verification/verification.md` - -Human-readable verification summary: what was checked, what passed, what remains uncertain. - -### `verification/checks.json` - -Machine-readable verification entries: - -- command -- result -- timestamp -- scope -- expected versus actual - -## Instruction File Strategy - -`AGENTS.md` and `CLAUDE.md` should be treated as equivalent instruction surfaces when used as agent-control files. - -For these files: - -- prefer symlink or alias normalization where practical -- do not duplicate full content unnecessarily -- extract and store normalized constraints in `constraints.json` - -For other context files: - -- store file path -- store extracted constraints or relevant facts -- do not store full snapshots by default - -This keeps token cost low and makes the system robust if source files change. - -## State Buckets and Merge Rules - -## Session State - -Contains: - -- goal -- status -- next action -- recent summary -- recent raw tail -- active mode if known - -Rule: latest session wins. - -On import: - -- replace goal -- replace status -- replace next action -- replace recent summary -- replace conversation tail - -Reason: session state is inherently current, not cumulative. - -## Execution State - -Contains: - -- tasks -- active plan -- files read -- files touched -- verification checks - -Rules: - -- tasks: merge by stable id/title, preserve status progression -- active plan: one active plan only, archive others by reference -- files read/touched: ordered dedup preserving recency -- verification entries: merge by check identity, prefer newest result - -## Project Memory - -Contains long-lived reusable knowledge. - -Rule: merge with dedup and provenance. - -Behavior: - -- merge imported and existing entries -- deduplicate semantically where possible -- preserve source, timestamp, and adapter metadata -- record merge decisions in `memory-merge-log.jsonl` - -This policy should be user-configurable in the future. v1 uses merge+dedup as the default. - -## Instruction Constraints - -Rule: - -- recompute extracted constraints from live source files when available -- if files are unavailable, fall back to last extracted snapshot -- never blindly merge raw instruction file text - -## Provenance - -Each imported fact should record: - -- source path -- adapter -- capture time -- whether it was copied, extracted, inferred, or summarized - -This is necessary for debugging merge behavior and maintaining trust. - -## Always-On Sync Model - -Always-on sync should be limited to structured state updates. - -Continuously maintained: - -- `tasks/tasks.json` -- `session/current.json` -- `session/next-action.md` -- `context/files-read.json` -- `context/files-touched.json` -- `verification/checks.json` -- `memory/project-memory.json` -- `context/constraints.json` - -Updated less frequently: - -- `session/recent-summary.md` -- `session/conversation-tail.md` -- `restore.md` - -This keeps steady-state token cost low because the system mostly performs local file writes rather than repeated summarization. - -## Checkpoint Flow - -Checkpoint behavior: - -1. Read canonical `.handoff/` state -2. Import richer state from adapters if available -3. Normalize into canonical schema -4. Refresh: - - recent summary - - short raw tail - - restore packet -5. Write timestamped bundle under `artifacts/exports/` - -The export bundle is for portability and audit. `.handoff/` remains the live source of truth. - -## Resume Flow - -Resume behavior: - -1. Detect available sources: - - `.handoff/` - - OMX/OMC state if present - - repo instruction files - - git metadata if available -2. Validate schema/version compatibility -3. Merge imported state into canonical form -4. Recompute constraints from live files where possible -5. Generate or refresh `restore.md` -6. Present the compact restore brief to the active tool - -## Conversation Portability Strategy - -Conversation portability uses: - -- a compact recent summary -- a short raw tail plus minimal annotations - -Recommended v1 raw tail policy: - -- keep only the last 3 to 8 relevant turns -- apply a hard size ceiling -- preserve only user/assistant content relevant to active work -- exclude noisy logs by default - -This preserves recent nuance without trying to migrate an entire transcript. - -## Token Cost Strategy - -The core token strategy is: - -- sync structure continuously -- compile narrative only on checkpoint/resume -- never continuously compress the full transcript - -This is the main optimization that makes the system practical. - -## OMX/OMC Adapter in v1 - -If OMX/OMC is available, the adapter should import from: - -- `.omx/notepad.md` -- `.omx/plans/*` -- `.omx/state/*` -- `.omx/project-memory.json` if present -- `.omx/logs/*` only for optional activity summarization - -The adapter should map these into canonical `.handoff/` state. It should not treat `.omx/` as the lasting source of truth. - -If OMX/OMC is unavailable, the system should operate in raw mode using: - -- `.handoff/` -- repo files -- instruction files -- git metadata where available - -## Why Tool-Agnostic Core + OMX Adapter - -This approach: - -- avoids hard dependence on OMX/OMC -- keeps the system portable -- improves fidelity when richer local state exists -- supports future adapters without redesigning the core model - -## Failure Modes and Risk Handling - -### Risk: stale extracted constraints - -Mitigation: - -- recompute from live files on resume when available -- keep source paths and timestamps - -### Risk: summary drift or hallucinated state - -Mitigation: - -- prefer structured state as the durable source -- keep summaries derived from canonical files -- never let summary overwrite factual structured records - -### Risk: overuse of conversation tail - -Mitigation: - -- cap raw tail size -- use summary as the primary semantic bridge - -### Risk: tool-specific lock-in - -Mitigation: - -- keep `.handoff/` canonical -- isolate adapters behind import/export boundaries - -### Risk: merge corruption in project memory - -Mitigation: - -- provenance tracking -- append-only merge log -- future support for replace/append/merge policies - -## Open Design Constraints for Implementation - -- v1 should not depend on a single runtime -- v1 should include OMX/OMC adapter support if present -- v1 should remain usable in a plain repo with no adapters -- all canonical state must remain human-inspectable -- migration must be honest about what is reconstructed versus preserved - -## Recommended v1 Scope - -Implement: - -- canonical `.handoff/` layout -- schema versioning and manifest -- structured always-on sync -- explicit checkpoint and resume -- restore packet generation -- raw mode -- OMX/OMC adapter -- merge+dedup project memory behavior -- path + extracted constraints for non-instruction context files -- instruction aliasing for `AGENTS.md` and `CLAUDE.md` - -Exclude from v1: - -- full transcript archive as primary restore mechanism -- deep semantic dedup via heavy LLM use on every update -- tool-specific direct runtime resume -- cloning hidden model state - -## ADR - -- **Decision:** Build a tool-agnostic canonical handoff layer under `.handoff/` with an optional OMX/OMC adapter in v1. -- **Drivers:** portability, low token cost, high-fidelity durable state, honest migration semantics. -- **Alternatives considered:** OMX-only storage; raw checkpoint-only mode; full session journal first. -- **Why chosen:** best balance of portability, fidelity, and implementation complexity. -- **Consequences:** adapter interfaces and normalization logic must exist from the start; hidden state remains non-portable. -- **Follow-ups:** define schemas; define adapter interfaces; define restore packet format; define merge/dedup behavior in implementation detail. diff --git a/docs/superpowers/specs/2026-04-12-agent-centric-handoff-design.md b/docs/superpowers/specs/2026-04-12-agent-centric-handoff-design.md deleted file mode 100644 index c8b3940..0000000 --- a/docs/superpowers/specs/2026-04-12-agent-centric-handoff-design.md +++ /dev/null @@ -1,266 +0,0 @@ -# Agent-Centric Handoff Design - -## Purpose - -Design an agent-agnostic handoff system whose primary UX is two skills: - -- `/handoff` -- `/get-handoff` - -The system should let a new agent resume work from one or more prior agents with minimal user effort and maximal durable context, without relying on a user-facing CLI. - -## Problem Statement - -The current implementation is centered on a session-oriented canonical `.handoff/` store plus CLI commands such as `capture`, `checkpoint`, `resume`, and `export`. - -That surface is useful for experimentation, but it is not the ideal product shape for a multi-agent environment: - -- the user has to move in and out of CLI commands -- the current state model is oriented around one active session, not many named agents -- parallel agents risk colliding if they target one shared handoff state -- downstream agents need a direct import surface, not a manual export/paste workflow - -## Goals - -- Make the product surface skill-first, not CLI-first -- Make the storage model agent-centric -- Support explicit multi-agent import such as `/get-handoff A,B` -- Keep the implementation agent/runtime agnostic -- Preserve honest limits around hidden model state -- Keep the on-disk state inspectable and mergeable -- Minimize user effort for both capture and resume - -## Non-Goals - -- Perfect transfer of hidden model state -- Replacing git, docs, or issue trackers -- Solving workstream-level merge semantics in v1 -- Supporting shared multi-writer state under one top-level session record -- Keeping the current CLI as a first-class product surface - -## Chosen Strategy - -Use **agent-centric snapshots** under `.handoff/agents//`, plus a merged import artifact under `.handoff/imports/`. - -### Primary user flow - -Source agent: - -```text -/handoff -``` - -or: - -```text -/handoff A -``` - -Receiving agent: - -```text -/get-handoff A,B -``` - -### Identity model - -Identity is **hybrid**: - -- `/handoff` uses the current agent identity if the runtime already knows it -- `/handoff ` may explicitly override the name -- after capture, the skill must print the resolved agent name so the user knows what to pass into `/get-handoff` -- `/get-handoff` always requires explicit source agent names - -## Why Agent-Centric Instead Of Workstream-Centric - -Agent-centric storage avoids multi-writer conflicts. If two agents touch the same workstream, they still write to distinct namespaces. This keeps v1 simple and makes ownership visible. - -Workstream-level composition can be added later, but it should be derived from per-agent state rather than replacing it. - -## Storage Layout - -```text -.handoff/ - agents/ - A/ - snapshot.json - summary.md - B/ - snapshot.json - summary.md - imports/ - current-get-handoff.json - current-get-handoff.md - shared/ - constraints.json - project-memory.json -``` - -### `agents//snapshot.json` - -Canonical structured snapshot for a single named agent. - -Suggested fields: - -```json -{ - "agent": "A", - "timestamp": "2026-04-12T00:00:00Z", - "runtime": "codex", - "summary": "What was just completed and what matters now.", - "next_action": "Exact next step.", - "open_tasks": ["..."], - "key_decisions": ["..."], - "blockers": ["..."], - "files_touched": ["src/..."], - "files_read_first": ["README.md", "src/..."], - "verification": ["tests run", "checks pending"], - "confidence": "medium", - "uncertainties": ["..."], - "provenance": { - "source": "handoff-skill" - } -} -``` - -### `agents//summary.md` - -Human-readable rendering of the snapshot. This is not the source of truth. - -### `imports/current-get-handoff.json` - -Structured merged import artifact produced by `/get-handoff`. - -### `imports/current-get-handoff.md` - -Compact landing document for the receiving agent. This is the first file a resumed agent should read. - -## `/handoff` Skill Contract - -The `/handoff` skill runs inside the current live agent session and externalizes a rich snapshot for one agent. - -### Required capture fields - -- summary -- next action -- open tasks -- key decisions -- blockers -- files touched -- files to read first -- verification state -- confidence / uncertainty -- timestamp -- resolved agent name - -### Behavior - -1. Resolve the agent name from explicit argument or session-default identity -2. Summarize the current live session into the canonical snapshot fields -3. Write `.handoff/agents//snapshot.json` -4. Write `.handoff/agents//summary.md` -5. Update any shared durable context if still relevant -6. Print a completion message that includes the resolved agent name - -### Required completion message - -The skill must end with something equivalent to: - -```text -handoff saved for agent: A -``` - -## `/get-handoff` Skill Contract - -The `/get-handoff` skill reads one or more named agent snapshots and produces one merged resume context for the current agent. - -### Invocation - -```text -/get-handoff A,B -``` - -### Behavior - -1. Parse the requested source agent names -2. Read each `.handoff/agents//snapshot.json` -3. Sort snapshots by timestamp descending -4. Produce a merged import artifact -5. Write `.handoff/imports/current-get-handoff.json` -6. Write `.handoff/imports/current-get-handoff.md` -7. Return the merged resume context - -## Merge Semantics - -v1 uses **newest wins** for authoritative top-level state. - -That means: - -- the newest snapshot becomes the primary source for scalar fields such as `summary`, `next_action`, and `confidence` -- older snapshots are not discarded; they are included as supporting context in the merged artifact -- the receiving agent sees one primary resume brief plus an appendix of older contributing agent snapshots - -This keeps conflict handling simple while preserving context. - -## Shared Durable Context - -Some data is not inherently agent-local and should remain shared: - -- extracted constraints -- durable project memory -- stable architecture notes - -This shared state should live under `.handoff/shared/` and be referenced by both skills. - -## Portability Boundary - -### Portable with high fidelity - -- explicit summaries -- next actions -- decisions -- blockers -- file references -- verification notes -- durable project memory -- instruction-derived constraints - -### Partially portable - -- relative salience of facts in the prior context window -- emphasis implied by the source session -- nuanced “what I was about to do” unless captured explicitly - -### Not portable - -- hidden model reasoning state -- opaque tool-private runtime state never written down -- exact internal pause/resume position of the source agent - -## Cleanup Direction - -This design intentionally retires the current CLI-first product surface. The remaining product should be: - -- `/handoff` -- `/get-handoff` - -Internal library code may still exist to support canonical reading, writing, and merging, but end-user docs, tests, and packaging should no longer present CLI commands as the primary interface. - -## Testing Strategy - -The implementation should be verified through: - -- unit tests for snapshot write/read/merge behavior -- skill asset tests for `/handoff` and `/get-handoff` -- fixture-driven tests for newest-wins imports -- integration tests that simulate `A -> C` and `A + B -> C` -- cleanup tests that ensure obsolete CLI surfaces are removed from docs and packaging - -## Follow-Up - -The next implementation plan should: - -- remove the public CLI surface -- refactor the canonical store from session-centric to agent-centric -- add the two skill assets and their installation packaging -- rewrite docs and tests around the skill-first workflow diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..bbd123c --- /dev/null +++ b/install.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./install.sh [--mode symlink|copy] [--home DIR] [--source DIR] + +Installs the handoff and get-handoff skills into a local agent runtime. + +Options: + --mode symlink Link installed skills to this checkout. Default. + --mode copy Copy skill files into the target runtime. + --home DIR Override HOME for testing or custom installs. + --source DIR Use a custom source directory containing skill folders. + -h, --help Show this help. +USAGE +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +runtime="" +mode="symlink" +home_dir="${HOME:-}" +source_dir="${script_dir}/skills" + +while [ "$#" -gt 0 ]; do + case "$1" in + codex|claude|both) + if [ -n "$runtime" ]; then + echo "runtime already set: ${runtime}" >&2 + exit 2 + fi + runtime="$1" + ;; + --mode) + if [ "$#" -lt 2 ]; then + echo "--mode requires symlink or copy" >&2 + exit 2 + fi + mode="$2" + shift + ;; + --home) + if [ "$#" -lt 2 ]; then + echo "--home requires a directory" >&2 + exit 2 + fi + home_dir="$2" + shift + ;; + --source) + if [ "$#" -lt 2 ]; then + echo "--source requires a directory" >&2 + exit 2 + fi + source_dir="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [ -z "$runtime" ]; then + usage >&2 + exit 2 +fi + +if [ -z "$home_dir" ]; then + echo "HOME is unset; pass --home DIR" >&2 + exit 2 +fi + +case "$mode" in + symlink|copy) ;; + *) + echo "unsupported mode: ${mode}" >&2 + exit 2 + ;; +esac + +case "$source_dir" in + /*) ;; + *) source_dir="${PWD}/${source_dir}" ;; +esac + +skill_names=("handoff" "get-handoff") + +validate_source() { + local name + for name in "${skill_names[@]}"; do + if [ ! -f "${source_dir}/${name}/SKILL.md" ]; then + echo "${source_dir} must contain ${name}/SKILL.md" >&2 + exit 1 + fi + done +} + +target_root_for() { + case "$1" in + codex) printf '%s\n' "${home_dir}/.codex/skills" ;; + claude) printf '%s\n' "${home_dir}/.claude/skills" ;; + *) + echo "unsupported runtime: $1" >&2 + exit 2 + ;; + esac +} + +replace_target() { + local target="$1" + if [ -L "$target" ] || [ -f "$target" ]; then + rm -f "$target" + elif [ -d "$target" ]; then + rm -rf "$target" + fi +} + +install_runtime() { + local runtime_name="$1" + local target_root + local name + local source + local target + + target_root="$(target_root_for "$runtime_name")" + mkdir -p "$target_root" + + for name in "${skill_names[@]}"; do + source="${source_dir}/${name}" + target="${target_root}/${name}" + replace_target "$target" + + if [ "$mode" = "symlink" ]; then + ln -s "$source" "$target" + else + cp -R "$source" "$target" + fi + + printf 'installed %s -> %s\n' "$name" "$target" + done +} + +validate_source + +case "$runtime" in + both) + install_runtime codex + install_runtime claude + ;; + codex|claude) + install_runtime "$runtime" + ;; +esac diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 1237ecc..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "portable-handoff" -version = "0.1.0" -description = "Portable handoff state for cross-agent resume" -readme = "README.md" -requires-python = ">=3.11" - -[tool.setuptools] -package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -handoff = ["assets/handoff/SKILL.md", "assets/get-handoff/SKILL.md"] diff --git a/src/handoff/__init__.py b/src/handoff/__init__.py deleted file mode 100644 index a05eb9a..0000000 --- a/src/handoff/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["__version__"] - -__version__ = "0.1.0" diff --git a/src/handoff/adapters/__init__.py b/src/handoff/adapters/__init__.py deleted file mode 100644 index da782de..0000000 --- a/src/handoff/adapters/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from handoff.adapters.base import Adapter, read_text -from handoff.adapters.omx import OMXAdapter -from handoff.adapters.raw import RawAdapter - -__all__ = ["Adapter", "OMXAdapter", "RawAdapter", "read_text"] diff --git a/src/handoff/adapters/base.py b/src/handoff/adapters/base.py deleted file mode 100644 index e714b12..0000000 --- a/src/handoff/adapters/base.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path -from typing import Any, Protocol - - -class Adapter(Protocol): - name: str - - def available(self) -> bool: ... - - def capture(self) -> dict[str, Any]: ... - - -def read_text(path: Path) -> str: - return path.read_text() if path.exists() else "" diff --git a/src/handoff/adapters/omx.py b/src/handoff/adapters/omx.py deleted file mode 100644 index ed4c0bb..0000000 --- a/src/handoff/adapters/omx.py +++ /dev/null @@ -1,45 +0,0 @@ -import json -from pathlib import Path -from typing import Any - -from handoff.adapters.base import read_text - - -class OMXAdapter: - name = "omx" - - def __init__(self, root: Path) -> None: - self.root = root - - def available(self) -> bool: - return any( - ( - (self.root / "notepad.md").exists(), - (self.root / "plans").is_dir(), - (self.root / "state" / "session.json").exists(), - (self.root / "project-memory.json").exists(), - ) - ) - - def capture(self) -> dict[str, Any]: - plans_dir = self.root / "plans" - plan_paths = sorted(plans_dir.glob("*.md")) if plans_dir.exists() else [] - - notes_path = self.root / "notepad.md" - session_path = self.root / "state" / "session.json" - session = json.loads(read_text(session_path)) if session_path.exists() else {} - - project_memory_path = self.root / "project-memory.json" - project_memory = ( - json.loads(read_text(project_memory_path)) - if project_memory_path.exists() - else {"entries": []} - ) - - return { - "adapter": self.name, - "notes": read_text(notes_path) if notes_path.exists() else "", - "plans": [read_text(path) for path in plan_paths], - "session": session, - "project_memory": project_memory, - } diff --git a/src/handoff/adapters/raw.py b/src/handoff/adapters/raw.py deleted file mode 100644 index 423a0f0..0000000 --- a/src/handoff/adapters/raw.py +++ /dev/null @@ -1,15 +0,0 @@ -from pathlib import Path -from typing import Any - - -class RawAdapter: - name = "raw" - - def __init__(self, root: Path) -> None: - self.root = root - - def available(self) -> bool: - return True - - def capture(self) -> dict[str, Any]: - return {"adapter": self.name, "root": str(self.root)} diff --git a/src/handoff/assets/get-handoff/SKILL.md b/src/handoff/assets/get-handoff/SKILL.md deleted file mode 100644 index e4020a3..0000000 --- a/src/handoff/assets/get-handoff/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: get-handoff -description: Use when you need to resume from one or more named agent handoff snapshots such as `/get-handoff A,B`. ---- - -# Get Handoff - -## Overview - -Use `/get-handoff` to read one or more `.handoff/agents//snapshot.json` files, merge them, and generate a resumable context for the current agent. - -## When to Use - -- When the user asks to resume from named prior agents -- When the workflow is `A -> C` or `A + B -> C` -- When `.handoff/agents//` snapshots already exist - -## Invocation - -```text -/get-handoff A,B -``` - -Source agent names must be explicit. - -## Workflow - -1. Parse the requested source agent names. -2. Read `.handoff/agents//snapshot.json` for each source. -3. Sort snapshots by timestamp descending. -4. Merge using newest-wins primary state. -5. Write `.handoff/imports/current-get-handoff.json`. -6. Write `.handoff/imports/current-get-handoff.md`. -7. Return the merged resume context. - -## Merge Rule - -- Newest timestamp wins for primary summary and next action. -- Older snapshots remain as supporting context and should not be discarded. - -## Notes - -- Keep the merged output compact and execution-oriented. -- If a named agent snapshot is missing, report that clearly instead of guessing. diff --git a/src/handoff/assets/handoff/SKILL.md b/src/handoff/assets/handoff/SKILL.md deleted file mode 100644 index 751c186..0000000 --- a/src/handoff/assets/handoff/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: handoff -description: Use when you need to externalize the current agent session into a named portable handoff snapshot for another agent to resume from. ---- - -# Handoff - -## Overview - -Use `/handoff` to capture the current live session into `.handoff/agents//`. - -Keep the payload model-neutral. Runtime-specific names are provenance only. - -## When to Use - -- Before switching from one coding agent/runtime to another -- When the user asks to "handoff", "export context", or "make this resumable elsewhere" -- When another agent will later call `/get-handoff A,B` - -## Required Output - -Capture these fields from the live session: - -- summary -- next action -- open tasks -- key decisions -- blockers -- files touched -- files to read first -- verification state -- confidence / uncertainty - -If any of those are unclear from the session, ask the user only for the missing pieces. - -## Workflow - -1. Resolve the agent name from `/handoff ` if provided, otherwise use the current session identity. -2. Summarize the live session into the required snapshot fields. -3. Write `.handoff/agents//snapshot.json`. -4. Write `.handoff/agents//summary.md`. -5. End by telling the user the resolved agent name. - -## Notes - -- Keep the summary concise and execution-oriented. -- Use the exact completion message shape: `handoff saved for agent: `. -- The receiving side should later use `/get-handoff A,B` with explicit source agent names. diff --git a/src/handoff/capture.py b/src/handoff/capture.py deleted file mode 100644 index 88e08ae..0000000 --- a/src/handoff/capture.py +++ /dev/null @@ -1,157 +0,0 @@ -from pathlib import Path -from typing import Mapping - -from handoff.compiler import compile_agent_summary, compile_get_handoff_markdown -from handoff.constraints import extract_constraints -from handoff.merge import merge_snapshots -from handoff.memory import merge_project_memory -from handoff.store import HandoffStore - - -def capture_agent_handoff( - *, - root: Path, - agent: str, - runtime: str, - source: str, - summary: str, - next_action: str, - open_tasks: list[str], - key_decisions: list[str], - blockers: list[str] | None = None, - files_touched: list[str] | None = None, - files_read_first: list[str] | None = None, - verification: list[str] | None = None, - confidence: str = "medium", - uncertainties: list[str] | None = None, -) -> dict: - store = HandoffStore(root) - store.ensure_layout() - timestamp = store.timestamp() - - snapshot = { - "agent": agent, - "timestamp": timestamp, - "runtime": runtime, - "summary": summary, - "next_action": next_action, - "open_tasks": open_tasks, - "key_decisions": key_decisions, - "blockers": blockers or [], - "files_touched": files_touched or [], - "files_read_first": files_read_first or [], - "verification": verification or [], - "confidence": confidence, - "uncertainties": uncertainties or [], - "provenance": {"source": source}, - } - store.write_agent_snapshot(agent, snapshot) - store.write_agent_summary(agent, compile_agent_summary(snapshot)) - return snapshot - - -def get_handoff(root: Path, source_agents: list[str]) -> dict: - store = HandoffStore(root) - store.ensure_layout() - snapshots = [store.read_agent_snapshot(agent) for agent in source_agents] - merged = merge_snapshots(snapshots) - markdown = compile_get_handoff_markdown(merged) - store.write_import_artifacts(merged, markdown) - return merged - - -def resolve_agent_name( - *, - explicit_agent: str | None, - default_agent: str | None = None, - env: Mapping[str, str] | None = None, -) -> str: - if explicit_agent: - return explicit_agent - if default_agent: - return default_agent - if env is not None and env.get("HANDOFF_AGENT"): - return env["HANDOFF_AGENT"] - raise ValueError("Agent name could not be resolved") - - -def run_handoff( - *, - root: Path, - explicit_agent: str | None, - default_agent: str | None, - runtime: str, - source: str, - summary: str, - next_action: str, - open_tasks: list[str], - key_decisions: list[str], - blockers: list[str] | None = None, - files_touched: list[str] | None = None, - files_read_first: list[str] | None = None, - verification: list[str] | None = None, - confidence: str = "medium", - uncertainties: list[str] | None = None, - incoming_project_memory: dict | None = None, - env: Mapping[str, str] | None = None, -) -> str: - agent = resolve_agent_name( - explicit_agent=explicit_agent, - default_agent=default_agent, - env=env, - ) - store = HandoffStore(root) - store.ensure_layout() - - constraints = extract_constraints(root) - store.write_json( - "shared/constraints.json", - {"sources": constraints["sources"], "rules": constraints["rules"]}, - ) - - if incoming_project_memory is not None: - current_memory = store.read_json("shared/project-memory.json", {"entries": []}) - merged_memory, _ = merge_project_memory(current_memory, incoming_project_memory) - store.write_json("shared/project-memory.json", merged_memory) - - capture_agent_handoff( - root=root, - agent=agent, - runtime=runtime, - source=source, - summary=summary, - next_action=next_action, - open_tasks=open_tasks, - key_decisions=key_decisions, - blockers=blockers, - files_touched=files_touched, - files_read_first=files_read_first, - verification=verification, - confidence=confidence, - uncertainties=uncertainties, - ) - return f"handoff saved for agent: {agent}" - - -def run_get_handoff(*, root: Path, source_agents: list[str]) -> str: - store = HandoffStore(root) - store.ensure_layout() - merged = get_handoff(root, source_agents) - - constraints = store.read_json("shared/constraints.json", {"rules": []}) - project_memory = store.read_json("shared/project-memory.json", {"entries": []}) - merged["constraints"] = constraints.get("rules", []) - ordered_memory = sorted( - project_memory.get("entries", []), - key=lambda entry: entry.get("updated_at", ""), - reverse=True, - ) - merged["project_memory"] = [ - entry["value"] - for entry in ordered_memory - if isinstance(entry.get("value"), str) and entry["value"] - ] - - markdown = compile_get_handoff_markdown(merged) - store.write_import_artifacts(merged, markdown) - return markdown diff --git a/src/handoff/compiler.py b/src/handoff/compiler.py deleted file mode 100644 index 1e4e295..0000000 --- a/src/handoff/compiler.py +++ /dev/null @@ -1,171 +0,0 @@ -from textwrap import dedent - - -def compile_agent_summary(snapshot: dict) -> str: - sections = [ - f"# Agent Handoff: {snapshot['agent']}", - "", - "## Summary", - snapshot.get("summary", ""), - "", - "## Next Action", - snapshot.get("next_action", ""), - ] - return dedent("\n".join(sections) + "\n") - - -def compile_get_handoff_markdown(payload: dict) -> str: - primary = payload["snapshots"][0] - open_tasks = "\n".join(f"- {item}" for item in payload.get("open_tasks", [])) or "- None" - decisions = "\n".join(f"- {item}" for item in payload.get("key_decisions", [])) or "- None" - blockers = "\n".join(f"- {item}" for item in payload.get("blockers", [])) or "- None" - files_read_first = "\n".join(f"- {item}" for item in payload.get("files_read_first", [])) or "- None" - verification = "\n".join(f"- {item}" for item in payload.get("verification", [])) or "- None" - project_memory = "\n".join(f"- {item}" for item in payload.get("project_memory", [])) or "- None" - appendix_lines = [] - for snapshot in payload["snapshots"][1:]: - appendix_lines.extend( - [ - f"### Agent: {snapshot['agent']}", - snapshot.get("summary", ""), - "", - f"Next: {snapshot.get('next_action', '')}", - "", - ] - ) - - if not appendix_lines: - appendix_lines = ["- None"] - - sections = [ - "# Get Handoff", - "", - "## Primary Context", - f"Agent: {primary['agent']}", - "", - "## Summary", - primary.get("summary", ""), - "", - "## Next Action", - primary.get("next_action", ""), - "", - "## Open Tasks", - open_tasks, - "", - "## Key Decisions", - decisions, - "", - "## Blockers", - blockers, - "", - "## Files To Read First", - files_read_first, - "", - "## Verification", - verification, - "", - "## Shared Project Memory", - project_memory, - "", - "## Additional Agent Snapshots", - *appendix_lines, - ] - return dedent("\n".join(sections) + "\n") - - -def compile_restore( - *, - goal: str, - status: str, - next_action: str, - constraints: list[str], - tasks: list[str], - decisions: list[str], - verification: list[str], - captured_summary: str = "", -) -> str: - constraint_lines = "\n".join(f"- {item}" for item in constraints) or "- None" - task_lines = "\n".join(f"- {item}" for item in tasks) or "- None" - decision_lines = "\n".join(f"- {item}" for item in decisions) or "- None" - verification_lines = "\n".join(f"- {item}" for item in verification) or "- None" - sections = [ - "# Restore Brief", - "", - "## Goal", - goal, - "", - "## Status", - status, - ] - - if captured_summary: - sections.extend( - [ - "", - "## Captured Summary", - captured_summary, - ] - ) - - sections.extend( - [ - "", - "## Constraints", - constraint_lines, - "", - "## Open Tasks", - task_lines, - "", - "## Important Decisions", - decision_lines, - "", - "## Verification", - verification_lines, - "", - "## Exact Next Action", - next_action, - "", - "## Portability Boundary", - "- Durable state is portable through `.handoff/`.", - "- Hidden model state and opaque runtime state are not portable.", - ] - ) - - return dedent("\n".join(sections) + "\n") - - -def compile_llm_handoff( - *, - summary: str, - next_action: str, - tasks: list[str], - decisions: list[str], - constraints: list[str], -) -> str: - task_lines = "\n".join(f"- {item}" for item in tasks) or "- None" - decision_lines = "\n".join(f"- {item}" for item in decisions) or "- None" - constraint_lines = "\n".join(f"- {item}" for item in constraints) or "- None" - - sections = [ - "# LLM Handoff", - "", - "## Summary", - summary, - "", - "## Next Action", - next_action, - "", - "## Open Tasks", - task_lines, - "", - "## Key Decisions", - decision_lines, - "", - "## Constraints", - constraint_lines, - "", - "## Notes", - "- Use `.handoff/` as canonical state.", - "- Hidden model state is not portable.", - ] - return dedent("\n".join(sections) + "\n") diff --git a/src/handoff/constraints.py b/src/handoff/constraints.py deleted file mode 100644 index 24b83d5..0000000 --- a/src/handoff/constraints.py +++ /dev/null @@ -1,55 +0,0 @@ -from pathlib import Path -from typing import Any - - -def extract_constraints(root: Path) -> dict[str, Any]: - sources: list[str] = [] - rules: list[str] = [] - context_files: list[dict[str, Any]] = [] - aliases = [ - { - "canonical": "AGENTS.md", - "equivalents": ["CLAUDE.md"], - } - ] - - for name in ("AGENTS.md", "CLAUDE.md"): - path = root / name - if not path.exists(): - continue - sources.append(str(path)) - for line in path.read_text().splitlines(): - stripped = line.strip().lstrip("-").strip() - if stripped: - rules.append(stripped) - - for path in sorted(root.rglob("*.md")): - if path.name in {"AGENTS.md", "CLAUDE.md"}: - continue - - lines = path.read_text().splitlines() - facts = [] - for line in lines: - stripped = line.strip().lstrip("#-").strip() - if stripped: - facts.append(stripped) - - if not facts: - continue - - excerpt = " ".join(line.strip() for line in lines if line.strip())[:200] - context_files.append( - { - "path": str(path), - "excerpt": excerpt, - "facts": facts, - } - ) - - deduped_rules = list(dict.fromkeys(rules)) - return { - "sources": sources, - "rules": deduped_rules, - "aliases": aliases, - "context_files": context_files, - } diff --git a/src/handoff/memory.py b/src/handoff/memory.py deleted file mode 100644 index 9a78b92..0000000 --- a/src/handoff/memory.py +++ /dev/null @@ -1,30 +0,0 @@ -from copy import deepcopy -from typing import Any - - -def merge_project_memory( - current: dict[str, Any], incoming: dict[str, Any] -) -> tuple[dict[str, Any], list[dict[str, str]]]: - merged = deepcopy(current) - entries = {entry["key"]: deepcopy(entry) for entry in merged.get("entries", [])} - merge_log: list[dict[str, str]] = [] - - for entry in incoming.get("entries", []): - key = entry["key"] - if key not in entries: - entries[key] = deepcopy(entry) - merge_log.append({"action": "insert", "key": key}) - continue - - existing = entries[key] - existing_sources = list( - dict.fromkeys(existing.get("sources", []) + entry.get("sources", [])) - ) - existing["sources"] = existing_sources - if entry.get("updated_at", "") >= existing.get("updated_at", ""): - existing["value"] = entry["value"] - existing["updated_at"] = entry["updated_at"] - merge_log.append({"action": "merge", "key": key}) - - merged["entries"] = list(entries.values()) - return merged, merge_log diff --git a/src/handoff/merge.py b/src/handoff/merge.py deleted file mode 100644 index 754b053..0000000 --- a/src/handoff/merge.py +++ /dev/null @@ -1,40 +0,0 @@ -from datetime import datetime, timezone - - -def merge_snapshots(snapshots: list[dict]) -> dict: - if not snapshots: - raise ValueError("At least one snapshot is required") - - ordered = sorted(snapshots, key=_timestamp_key, reverse=True) - primary = ordered[0] - return { - "primary_agent": primary["agent"], - "summary": primary["summary"], - "next_action": primary["next_action"], - "confidence": primary.get("confidence", "medium"), - "sources": [item["agent"] for item in ordered], - "open_tasks": _dedupe_from_snapshots(ordered, "open_tasks"), - "key_decisions": _dedupe_from_snapshots(ordered, "key_decisions"), - "blockers": _dedupe_from_snapshots(ordered, "blockers"), - "files_touched": _dedupe_from_snapshots(ordered, "files_touched"), - "files_read_first": _dedupe_from_snapshots(ordered, "files_read_first"), - "verification": _dedupe_from_snapshots(ordered, "verification"), - "uncertainties": _dedupe_from_snapshots(ordered, "uncertainties"), - "snapshots": ordered, - } - - -def _dedupe_from_snapshots(snapshots: list[dict], field: str) -> list[str]: - values: list[str] = [] - for snapshot in snapshots: - values.extend(snapshot.get(field, [])) - return list(dict.fromkeys(value for value in values if value)) - - -def _timestamp_key(snapshot: dict) -> datetime: - timestamp = snapshot["timestamp"] - normalized = timestamp[:-1] + "+00:00" if timestamp.endswith("Z") else timestamp - parsed = datetime.fromisoformat(normalized) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) diff --git a/src/handoff/models.py b/src/handoff/models.py deleted file mode 100644 index 4f4f0d1..0000000 --- a/src/handoff/models.py +++ /dev/null @@ -1,54 +0,0 @@ -from dataclasses import asdict, dataclass, field -from typing import Any - - -@dataclass -class AgentSnapshot: - agent: str - timestamp: str - runtime: str = "unknown" - summary: str = "" - next_action: str = "" - open_tasks: list[str] = field(default_factory=list) - key_decisions: list[str] = field(default_factory=list) - blockers: list[str] = field(default_factory=list) - files_touched: list[str] = field(default_factory=list) - files_read_first: list[str] = field(default_factory=list) - verification: list[str] = field(default_factory=list) - confidence: str = "medium" - uncertainties: list[str] = field(default_factory=list) - provenance: dict[str, str] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass -class Manifest: - schema_version: str = "1" - active_adapter: str = "raw" - created_at: str = "" - updated_at: str = "" - last_checkpoint_at: str | None = None - last_resume_at: str | None = None - integrity: dict[str, str] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - -@dataclass -class SessionState: - goal: str = "" - status: str = "idle" - next_action: str = "" - active_mode: str | None = None - timestamp: str = "" - last_checkpoint_at: str | None = None - last_adapter_used: str = "raw" - captured_summary: str = "" - captured_open_tasks: list[str] = field(default_factory=list) - captured_key_decisions: list[str] = field(default_factory=list) - - def to_dict(self) -> dict[str, Any]: - return asdict(self) diff --git a/src/handoff/store.py b/src/handoff/store.py deleted file mode 100644 index 718506e..0000000 --- a/src/handoff/store.py +++ /dev/null @@ -1,122 +0,0 @@ -import hashlib -import json -import re -from datetime import datetime, timezone -from pathlib import Path - - -_AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") - - -class HandoffStore: - CANONICAL_DIRECTORIES = ( - "agents", - "imports", - "shared", - ) - - CANONICAL_JSON_FILES = ( - "shared/constraints.json", - "shared/project-memory.json", - ) - CANONICAL_TEXT_FILES: tuple[str, ...] = () - - def __init__(self, root: Path) -> None: - self.root = root - self.base = root / ".handoff" - - def ensure_layout(self) -> None: - timestamp = self._timestamp() - - for relative in self.CANONICAL_DIRECTORIES: - (self.base / relative).mkdir(parents=True, exist_ok=True) - - defaults = { - "shared/constraints.json": {"sources": [], "rules": []}, - "shared/project-memory.json": {"entries": []}, - } - - for relative, payload in defaults.items(): - path = self.base / relative - if not path.exists(): - self._write_json(relative, payload) - - for relative in self.CANONICAL_TEXT_FILES: - path = self.base / relative - path.touch(exist_ok=True) - - def read_json(self, relative: str, default: dict | None = None) -> dict: - path = self.base / relative - if not path.exists(): - return {} if default is None else default - return json.loads(path.read_text()) - - def write_json(self, relative: str, payload: dict) -> Path: - self._write_json(relative, payload) - return self.base / relative - - def append_jsonl(self, relative: str, entries: list[dict]) -> Path: - path = self.base / relative - if not entries: - return path - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as handle: - for entry in entries: - handle.write(json.dumps(entry, sort_keys=True) + "\n") - return path - - def timestamp(self) -> str: - return self._timestamp() - - def canonical_layout_fingerprint(self) -> str: - return self._layout_fingerprint() - - def _write_json(self, relative: str, payload: dict) -> None: - path = self.base / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") - - def write_agent_snapshot(self, agent: str, payload: dict) -> Path: - path = self._agent_dir(agent) / "snapshot.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") - return path - - def read_agent_snapshot(self, agent: str) -> dict: - path = self._agent_dir(agent) / "snapshot.json" - if not path.exists(): - raise FileNotFoundError(f"snapshot.json not found for agent {agent}") - return json.loads(path.read_text()) - - def write_agent_summary(self, agent: str, content: str) -> Path: - path = self._agent_dir(agent) / "summary.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - return path - - def write_import_artifacts(self, payload: dict, content: str) -> None: - self._write_json("imports/current-get-handoff.json", payload) - path = self.base / "imports" / "current-get-handoff.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - - def _agent_dir(self, agent: str) -> Path: - if not _AGENT_ID_PATTERN.fullmatch(agent): - raise ValueError( - "Agent names may only contain letters, numbers, dots, underscores, " - "and dashes, and must start with a letter or number" - ) - return self.base / "agents" / agent - - def _layout_fingerprint(self) -> str: - entries = sorted( - self.CANONICAL_DIRECTORIES - + self.CANONICAL_JSON_FILES - + self.CANONICAL_TEXT_FILES - ) - digest = hashlib.sha256() - digest.update("\n".join(entries).encode("utf-8")) - return digest.hexdigest() - - def _timestamp(self) -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/tests/fixtures/omx/notepad.md b/tests/fixtures/omx/notepad.md deleted file mode 100644 index f1c6601..0000000 --- a/tests/fixtures/omx/notepad.md +++ /dev/null @@ -1,2 +0,0 @@ -## WORKING MEMORY -[2026-04-09T00:00:00Z] Working memory: build canonical .handoff store first. diff --git a/tests/fixtures/omx/plans/2026-04-08-sample-plan.md b/tests/fixtures/omx/plans/2026-04-08-sample-plan.md deleted file mode 100644 index 20b3e01..0000000 --- a/tests/fixtures/omx/plans/2026-04-08-sample-plan.md +++ /dev/null @@ -1,3 +0,0 @@ -# Sample Plan - -- Bootstrap canonical store diff --git a/tests/fixtures/omx/project-memory.json b/tests/fixtures/omx/project-memory.json deleted file mode 100644 index 93987f6..0000000 --- a/tests/fixtures/omx/project-memory.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "entries": [ - { - "key": "architecture:handoff", - "value": ".handoff is canonical", - "sources": [ - "omx" - ], - "updated_at": "2026-04-09T00:00:00Z" - } - ] -} diff --git a/tests/fixtures/omx/state/session.json b/tests/fixtures/omx/state/session.json deleted file mode 100644 index 7b397c8..0000000 --- a/tests/fixtures/omx/state/session.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "cwd": "/workspace/project", - "session_id": "abc123" -} diff --git a/tests/test_agent_handoff_integration.py b/tests/test_agent_handoff_integration.py deleted file mode 100644 index 875d071..0000000 --- a/tests/test_agent_handoff_integration.py +++ /dev/null @@ -1,71 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.capture import capture_agent_handoff, get_handoff - - -class AgentHandoffIntegrationTest(unittest.TestCase): - def test_single_agent_get_handoff_uses_that_agent_as_primary(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_agent_handoff( - root=root, - agent="A", - runtime="codex", - source="handoff-skill", - summary="Agent A summary", - next_action="Agent A next", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - - merged = get_handoff(root, ["A"]) - - self.assertEqual(merged["primary_agent"], "A") - import_markdown = (root / ".handoff" / "imports" / "current-get-handoff.md").read_text() - self.assertIn("Agent: A", import_markdown) - - def test_multi_agent_get_handoff_uses_newest_snapshot_as_primary(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - capture_agent_handoff( - root=root, - agent="A", - runtime="codex", - source="handoff-skill", - summary="Agent A summary", - next_action="Agent A next", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - snapshot_b = capture_agent_handoff( - root=root, - agent="B", - runtime="claude", - source="handoff-skill", - summary="Agent B summary", - next_action="Agent B next", - open_tasks=["Task B"], - key_decisions=["Decision B"], - ) - snapshot_a = json.loads((root / ".handoff" / "agents" / "A" / "snapshot.json").read_text()) - snapshot_b["timestamp"] = "9999-12-31T23:59:59Z" - (root / ".handoff" / "agents" / "B" / "snapshot.json").write_text( - json.dumps(snapshot_b, indent=2, sort_keys=True) + "\n" - ) - - merged = get_handoff(root, ["A", "B"]) - - self.assertEqual(snapshot_a["agent"], "A") - self.assertEqual(merged["primary_agent"], "B") - self.assertEqual(merged["summary"], "Agent B summary") - import_payload = json.loads( - (root / ".handoff" / "imports" / "current-get-handoff.json").read_text() - ) - self.assertEqual(import_payload["sources"], ["B", "A"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_agent_handoff_store.py b/tests/test_agent_handoff_store.py deleted file mode 100644 index d47b844..0000000 --- a/tests/test_agent_handoff_store.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.store import HandoffStore - - -class AgentStoreLayoutTest(unittest.TestCase): - def test_ensure_layout_creates_agent_and_import_roots(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - self.assertTrue((root / ".handoff" / "agents").exists()) - self.assertTrue((root / ".handoff" / "imports").exists()) - self.assertTrue((root / ".handoff" / "shared").exists()) - - def test_write_and_read_agent_snapshot(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - payload = { - "agent": "A", - "timestamp": "2026-04-12T00:00:00Z", - "summary": "Summary", - "next_action": "Next", - } - - path = store.write_agent_snapshot("A", payload) - - self.assertTrue(path.exists()) - self.assertEqual(store.read_agent_snapshot("A"), payload) - self.assertEqual(json.loads(path.read_text()), payload) - - def test_rejects_path_like_agent_names(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - store = HandoffStore(root) - store.ensure_layout() - - payload = { - "agent": "../outside", - "timestamp": "2026-04-12T00:00:00Z", - "summary": "Summary", - "next_action": "Next", - } - - with self.assertRaises(ValueError): - store.write_agent_snapshot("../outside", payload) - with self.assertRaises(ValueError): - store.read_agent_snapshot("/absolute") - with self.assertRaises(ValueError): - store.write_agent_summary("A/B", "summary") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_constraints.py b/tests/test_constraints.py deleted file mode 100644 index edfd4e0..0000000 --- a/tests/test_constraints.py +++ /dev/null @@ -1,45 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from handoff.constraints import extract_constraints - - -class ConstraintsTest(unittest.TestCase): - def test_extracts_rules_and_aliases_instruction_files(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - agents = root / "AGENTS.md" - agents.write_text( - "- Do not add dependencies\n- Prefer tests first\n" - ) - - result = extract_constraints(root) - - self.assertEqual(result["sources"], [str(agents)]) - self.assertIn("Do not add dependencies", result["rules"]) - self.assertEqual(result["aliases"][0]["canonical"], "AGENTS.md") - self.assertIn("CLAUDE.md", result["aliases"][0]["equivalents"]) - - def test_other_context_files_are_recorded_as_path_plus_excerpt(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - notes = root / "docs" / "notes.md" - notes.parent.mkdir() - notes.write_text("# Notes\n- Preserve recent summary\n") - - result = extract_constraints(root) - - self.assertEqual(result["sources"], []) - self.assertEqual(len(result["context_files"]), 1) - self.assertEqual(result["context_files"][0]["path"], str(notes)) - self.assertIn( - "Preserve recent summary", result["context_files"][0]["excerpt"] - ) - self.assertIn( - "Preserve recent summary", result["context_files"][0]["facts"] - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_get_handoff_merge.py b/tests/test_get_handoff_merge.py deleted file mode 100644 index b6667a4..0000000 --- a/tests/test_get_handoff_merge.py +++ /dev/null @@ -1,49 +0,0 @@ -import unittest - -from handoff.merge import merge_snapshots - - -class GetHandoffMergeTest(unittest.TestCase): - def test_newest_snapshot_wins_primary_fields(self) -> None: - older_snapshot = { - "agent": "A", - "timestamp": "2026-04-12T00:00:00Z", - "summary": "old summary", - "next_action": "old next", - } - newer_snapshot = { - "agent": "B", - "timestamp": "2026-04-12T01:00:00Z", - "summary": "new summary", - "next_action": "new next", - } - - merged = merge_snapshots([older_snapshot, newer_snapshot]) - - self.assertEqual(merged["primary_agent"], "B") - self.assertEqual(merged["summary"], "new summary") - self.assertIn("A", merged["sources"]) - self.assertIn("B", merged["sources"]) - - def test_timestamp_ordering_handles_iso_variants_by_instant(self) -> None: - older_snapshot = { - "agent": "A", - "timestamp": "2026-04-12T00:00:00Z", - "summary": "old summary", - "next_action": "old next", - } - newer_snapshot = { - "agent": "B", - "timestamp": "2026-04-12T00:00:00.500000+00:00", - "summary": "new summary", - "next_action": "new next", - } - - merged = merge_snapshots([older_snapshot, newer_snapshot]) - - self.assertEqual(merged["primary_agent"], "B") - self.assertEqual(merged["summary"], "new summary") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_handoff_rendering.py b/tests/test_handoff_rendering.py deleted file mode 100644 index 299e814..0000000 --- a/tests/test_handoff_rendering.py +++ /dev/null @@ -1,49 +0,0 @@ -import unittest - -from handoff.compiler import compile_agent_summary, compile_get_handoff_markdown - - -class HandoffRenderingTest(unittest.TestCase): - def test_compile_agent_summary_contains_core_sections(self) -> None: - text = compile_agent_summary( - { - "agent": "A", - "summary": "Done work", - "next_action": "Do next", - } - ) - - self.assertIn("# Agent Handoff: A", text) - self.assertIn("## Summary", text) - self.assertIn("## Next Action", text) - - def test_compile_get_handoff_render_contains_primary_and_appendix(self) -> None: - text = compile_get_handoff_markdown( - { - "primary_agent": "B", - "summary": "new summary", - "next_action": "new next", - "sources": ["B", "A"], - "snapshots": [ - { - "agent": "B", - "summary": "new summary", - "next_action": "new next", - }, - { - "agent": "A", - "summary": "old summary", - "next_action": "old next", - }, - ], - } - ) - - self.assertIn("# Get Handoff", text) - self.assertIn("## Primary Context", text) - self.assertIn("## Additional Agent Snapshots", text) - self.assertIn("Agent: B", text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_memory.py b/tests/test_memory.py deleted file mode 100644 index e7f3b47..0000000 --- a/tests/test_memory.py +++ /dev/null @@ -1,60 +0,0 @@ -import unittest - -from handoff.memory import merge_project_memory - - -class MemoryMergeTest(unittest.TestCase): - def test_merge_dedup_preserves_provenance(self) -> None: - current = { - "entries": [ - { - "key": "convention:stdlib-only", - "value": "Prefer stdlib only", - "sources": ["local"], - "updated_at": "2026-04-09T00:00:00Z", - } - ] - } - incoming = { - "entries": [ - { - "key": "convention:stdlib-only", - "value": "Prefer stdlib only", - "sources": ["omx"], - "updated_at": "2026-04-09T01:00:00Z", - }, - { - "key": "architecture:canonical-store", - "value": ".handoff is canonical", - "sources": ["spec"], - "updated_at": "2026-04-09T01:00:00Z", - }, - ] - } - - merged, log = merge_project_memory(current, incoming) - - self.assertEqual(len(merged["entries"]), 2) - self.assertIn("omx", merged["entries"][0]["sources"]) - self.assertGreaterEqual(len(log), 1) - - def test_resume_path_keeps_single_memory_entry_after_merge(self) -> None: - current = { - "entries": [ - {"key": "a", "value": "x", "sources": ["local"], "updated_at": "1"} - ] - } - incoming = { - "entries": [ - {"key": "a", "value": "x", "sources": ["omx"], "updated_at": "2"} - ] - } - - merged, _ = merge_project_memory(current, incoming) - - self.assertEqual(len(merged["entries"]), 1) - self.assertIn("omx", merged["entries"][0]["sources"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_omx_adapter.py b/tests/test_omx_adapter.py deleted file mode 100644 index 3a84103..0000000 --- a/tests/test_omx_adapter.py +++ /dev/null @@ -1,20 +0,0 @@ -import unittest -from pathlib import Path - -from handoff.adapters.omx import OMXAdapter - - -class OMXAdapterTest(unittest.TestCase): - def test_reads_notepad_plan_and_session(self) -> None: - fixture_root = Path(__file__).resolve().parent / "fixtures" / "omx" - adapter = OMXAdapter(fixture_root) - payload = adapter.capture() - - self.assertEqual(payload["adapter"], "omx") - self.assertIn("Working memory", payload["notes"]) - self.assertEqual(payload["session"]["cwd"], "/workspace/project") - self.assertEqual(len(payload["plans"]), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_skill.py b/tests/test_skill.py deleted file mode 100644 index aa90d7e..0000000 --- a/tests/test_skill.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest -from pathlib import Path - - -class SkillSurfaceTest(unittest.TestCase): - def test_repo_ships_handoff_and_get_handoff_skills(self) -> None: - repo = Path(__file__).resolve().parents[1] - handoff = repo / "skills" / "handoff" / "SKILL.md" - get_handoff = repo / "skills" / "get-handoff" / "SKILL.md" - - self.assertTrue(handoff.exists()) - self.assertTrue(get_handoff.exists()) - - handoff_text = handoff.read_text() - get_handoff_text = get_handoff.read_text() - self.assertIn("name: handoff", handoff_text) - self.assertIn("/handoff", handoff_text) - self.assertIn("name: get-handoff", get_handoff_text) - self.assertIn("/get-handoff", get_handoff_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_skill_workflow.py b/tests/test_skill_workflow.py deleted file mode 100644 index 78630f1..0000000 --- a/tests/test_skill_workflow.py +++ /dev/null @@ -1,117 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from handoff.capture import run_get_handoff, run_handoff - - -class SkillWorkflowTest(unittest.TestCase): - def test_run_handoff_uses_resolved_agent_name_and_updates_shared_constraints(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "AGENTS.md").write_text("- Prefer tests first\n") - - message = run_handoff( - root=root, - explicit_agent=None, - default_agent="agent-A", - runtime="codex", - source="handoff-skill", - summary="Finished parser cleanup", - next_action="Implement merge rendering", - open_tasks=["Task A"], - key_decisions=["Decision A"], - ) - - self.assertEqual(message, "handoff saved for agent: agent-A") - snapshot = json.loads( - (root / ".handoff" / "agents" / "agent-A" / "snapshot.json").read_text() - ) - self.assertEqual(snapshot["agent"], "agent-A") - constraints = json.loads( - (root / ".handoff" / "shared" / "constraints.json").read_text() - ) - self.assertIn("Prefer tests first", constraints["rules"]) - - def test_run_get_handoff_merges_newest_primary_and_combines_supporting_lists(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "AGENTS.md").write_text("- Prefer tests first\n") - - run_handoff( - root=root, - explicit_agent="A", - default_agent=None, - runtime="codex", - source="handoff-skill", - summary="Older summary", - next_action="Older next", - open_tasks=["Task A"], - key_decisions=["Decision A"], - blockers=["Blocker A"], - files_touched=["src/a.py"], - files_read_first=["README.md"], - verification=["unit A"], - incoming_project_memory={ - "entries": [ - { - "key": "architecture:a", - "value": "A note", - "sources": ["agent-A"], - "updated_at": "2026-04-12T00:00:00Z", - } - ] - }, - ) - run_handoff( - root=root, - explicit_agent="B", - default_agent=None, - runtime="claude", - source="handoff-skill", - summary="Newer summary", - next_action="Newer next", - open_tasks=["Task B"], - key_decisions=["Decision B"], - blockers=["Blocker B"], - files_touched=["src/b.py"], - files_read_first=["src/b.py"], - verification=["unit B"], - incoming_project_memory={ - "entries": [ - { - "key": "architecture:b", - "value": "B note", - "sources": ["agent-B"], - "updated_at": "2026-04-12T01:00:00Z", - } - ] - }, - ) - - markdown = run_get_handoff(root=root, source_agents=["A", "B"]) - - self.assertIn("# Get Handoff", markdown) - self.assertIn("Agent: B", markdown) - self.assertIn("Task A", markdown) - self.assertIn("Task B", markdown) - self.assertIn("Decision A", markdown) - self.assertIn("Decision B", markdown) - payload = json.loads( - (root / ".handoff" / "imports" / "current-get-handoff.json").read_text() - ) - self.assertEqual(payload["primary_agent"], "B") - self.assertEqual(payload["summary"], "Newer summary") - self.assertEqual(payload["next_action"], "Newer next") - self.assertEqual(payload["open_tasks"], ["Task B", "Task A"]) - self.assertEqual(payload["key_decisions"], ["Decision B", "Decision A"]) - self.assertEqual(payload["blockers"], ["Blocker B", "Blocker A"]) - self.assertEqual(payload["files_touched"], ["src/b.py", "src/a.py"]) - self.assertEqual(payload["files_read_first"], ["src/b.py", "README.md"]) - self.assertEqual(payload["verification"], ["unit B", "unit A"]) - self.assertEqual(payload["project_memory"], ["B note", "A note"]) - - -if __name__ == "__main__": - unittest.main()