Skip to content
Open
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"name": "workspace",
"source": "./plugins/workspace",
"description": "Bootstrap and manage multi-repo dev environments for AI-assisted development: clone repos from a domain, layer per-repo Claude context, and track work in structured project workspaces.",
"version": "0.1.2"
"version": "0.2.0"
}
]
}
2 changes: 1 addition & 1 deletion plugins/workspace/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "workspace",
"displayName": "Multi-Repo Workspace Manager",
"version": "0.1.2",
"version": "0.2.0",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"description": "Bootstrap and manage multi-repo dev environments for AI-assisted development: clone repos from a domain, layer per-repo Claude context, and track work in structured project workspaces.",
"author": {
"name": "fonta-rh"
Expand Down
16 changes: 13 additions & 3 deletions plugins/workspace/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ Two roots are kept strictly separate:

```text
.claude-plugin/{plugin.json, marketplace.json} Plugin + marketplace manifests
skills/<name>/SKILL.md 8 skills (workspace: prefix)
skills/<name>/SKILL.md 9 skills (workspace: prefix)
skills/create-domain/context-template.md Context-file template
hooks/hooks.json SessionStart → recent-projects.py
hooks/hooks.json SessionStart → recent-projects.py / handoff.py
scripts/setup.sh Clone/update/init CLI (self-derives plugin root)
scripts/workspace_lib.py Shared, yaml-free: resolve_workspace_root(), PLUGIN_ROOT
scripts/{resume,consolidate,recent}-project*.py Project tooling
scripts/domain-info.py Project→domain resolution, writability, copy-on-write
scripts/skills.py Repo-skill symlink manager (scan/link/verify/unlink-check)
scripts/handoff.py Checkpoint marker: write (skill) / read (hook)
domains/{example,tnf,lvm-operator}/ Bundled domains (read-only)
templates/{dev-env.yaml.template, dev-env-self.yaml.template, settings.local.json.tpl}
tests/{test_setup.sh, test_skills.py, test_domain_info.py} Test suites
tests/{test_setup.sh, test_skills.py, test_domain_info.py, test_handoff.py} Test suites
```

## Skills
Expand All @@ -45,6 +46,7 @@ tests/{test_setup.sh, test_skills.py, test_domain_info.py} Test suites
| `/workspace:setup-environment` | Set up / refresh a workspace from a domain |
| `/workspace:create-domain` | Build a custom workspace from arbitrary repos |
| `/workspace:new-project` | Create a new project workspace for a task |
| `/workspace:checkpoint` | Update project docs and arm a handoff for the next `/clear` |
| `/workspace:resume-project` | Resume an existing project |
| `/workspace:close-project` | Close a completed project (worktree cleanup) |
| `/workspace:update-project` | Update project docs from the session |
Expand All @@ -62,6 +64,14 @@ tests/{test_setup.sh, test_skills.py, test_domain_info.py} Test suites
- **PyYAML**: plugins can't declare python deps. `resume-project.py` emits a
self-describing JSON error when PyYAML is missing; `workspace_lib.py` and
`recent-projects.py` stay yaml-free so the SessionStart hook never needs it.
- **Checkpoint handoff**: `/workspace:checkpoint` writes a single-use marker
to `<workspace>/.claude/handoff.json`; the SessionStart hook on the `clear`
matcher (`handoff.py read`) consumes it and tells Claude to resume that
project, then falls through to `recent-projects.py` when no handoff is
armed. Marker TTL 60 min, schema version 1. `handoff.py` is yaml-free for
the same reason `recent-projects.py` is. `/clear` itself can never be
issued by Claude — it is not among the built-ins reachable through the
Skill tool — so the marker is how state crosses that boundary.
- Python scripts target **python3.9+** (macOS system python); they use
`from __future__ import annotations` so `X | None` hints don't break there.
- **Single-repo self-workspaces**: a top-level `self:` block (`name`,
Expand Down
5 changes: 4 additions & 1 deletion plugins/workspace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,17 @@ domain, use `/workspace:create-domain`.
| `/workspace:setup-environment` | Set up or refresh a workspace from a domain |
| `/workspace:create-domain` | Build a custom workspace from arbitrary repos, with collaboratively generated per-repo context |
| `/workspace:new-project` | Create a new project workspace for a task (bug, feature, CI, docs, analysis) |
| `/workspace:checkpoint` | Save session progress and arm a handoff so the next `/clear` resumes automatically |
| `/workspace:resume-project` | Resume an existing project — reload context and continue |
| `/workspace:close-project` | Close a completed project and clean up its worktrees |
| `/workspace:update-project` | Record what a session accomplished into the project docs |
| `/workspace:consolidate-project` | Archive completed checklist items from a bloated project CLAUDE.md |
| `/workspace:update-domain` | Feed lessons learned from a project back into its domain's context files |

A SessionStart hook surfaces your recent projects whenever you launch Claude
Code inside a workspace (it stays silent elsewhere).
Code inside a workspace (it stays silent elsewhere). After
`/workspace:checkpoint`, that same hook instead resumes the checkpointed
project on your next `/clear`.

## Concepts

Expand Down
10 changes: 10 additions & 0 deletions plugins/workspace/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|fork|compact",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/recent-projects.py\""
}
]
},
{
"matcher": "clear",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/handoff.py\" read"
}
]
}
]
}
Expand Down
235 changes: 235 additions & 0 deletions plugins/workspace/scripts/handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Arm and consume the checkpoint handoff marker.

`/workspace:checkpoint` writes a marker after updating a project's docs; the
SessionStart hook bound to the `clear` matcher consumes it and tells Claude to
resume that project. The marker is the only state that crosses a /clear.

Deliberately yaml-free: `read` runs on every /clear and plugins cannot declare
python dependencies, so this must never import a third-party module.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import workspace_lib

MARKER_VERSION = 1
TTL_SECONDS = 3600


def marker_path(root: Path) -> Path:
"""The single source of truth for where the marker lives.

Both subcommands go through this. A divergence here would make the
handoff silently never fire.
"""
return root / ".claude" / "handoff.json"


def emit(payload: dict) -> None:
print(json.dumps(payload, indent=2))


def unlink_quietly(path: Path) -> None:
try:
path.unlink()
except OSError:
pass


def passthrough() -> None:
"""Hand the session start to recent-projects.py, preserving its behavior.

execv replaces this process, so recent-projects.py's stdout becomes ours
and its banner rendering is never duplicated here. Returns only if the
exec itself fails, in which case staying silent is the safe outcome.
"""
script = Path(__file__).resolve().parent / "recent-projects.py"
try:
os.execv(sys.executable, [sys.executable, str(script)])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except OSError:
return


def parse_timestamp(raw: object) -> datetime | None:
"""Parse an ISO timestamp into an aware UTC datetime, or None."""
if not isinstance(raw, str):
return None
try:
stamp = datetime.fromisoformat(raw)
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.astimezone()
return stamp.astimezone(timezone.utc)


def load_marker(path: Path) -> dict | None:
"""Consume the marker: return it if fresh and valid, else None.

The file is deleted whenever it existed, whatever its state. Consumption
is single-use by construction, so a repeated /clear cannot re-fire.
Never raises: a bad marker must not disturb a session start.
"""
if not path.is_file():
return None
try:
raw = path.read_text()
except OSError:
unlink_quietly(path)
return None

unlink_quietly(path)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
data = json.loads(raw)
except ValueError:
return None
if not isinstance(data, dict) or data.get("version") != MARKER_VERSION:
return None
if not data.get("project") or not data.get("next_task"):
return None

written = parse_timestamp(data.get("written_at"))
if written is None:
return None

age = (datetime.now(timezone.utc) - written).total_seconds()
if age > TTL_SECONDS:
return None

# A negative age means clock skew, not a marker from the future.
data["_age_seconds"] = max(age, 0.0)
if not isinstance(data.get("load_files"), list):
data["load_files"] = []
return data
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def humanize_age(seconds: float) -> str:
minutes = int(seconds // 60)
if minutes < 1:
return "just now"
if minutes == 1:
return "1 minute ago"
if minutes < 60:
return f"{minutes} minutes ago"
hours = minutes // 60
return "1 hour ago" if hours == 1 else f"{hours} hours ago"


def build_directive(marker: dict) -> str:
files = ", ".join(marker["load_files"]) or "none recorded"
return (
f"Checkpoint handoff pending (saved {humanize_age(marker['_age_seconds'])}).\n\n"
f"Project: {marker['project']}\n"
f"Next task: {marker['next_task']}\n"
f"Detail files: {files}\n\n"
f"Invoke the workspace:resume-project skill with argument\n"
f"`{marker['project']}`. In Step 4, skip the task menu: read the detail\n"
f"files listed above and report readiness with the next task."
)


def cmd_write(args: argparse.Namespace) -> int:
root = workspace_lib.resolve_workspace_root()
if root is None:
emit({
"status": "error",
"message": "Could not determine the workspace root. Set WORKSPACE_ROOT "
"or run inside a workspace (a directory containing dev-env.yaml).",
})
return 0

project_dir = root / "projects" / args.project
if not project_dir.is_dir():
emit({
"status": "error",
"message": f"No such project: {args.project} (expected {project_dir})",
})
Comment thread
fonta-rh marked this conversation as resolved.
return 0

load_files = [f.strip() for f in (args.load_files or "").split(",") if f.strip()]

payload = {
"version": MARKER_VERSION,
"project": args.project,
"written_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"next_task": args.next_task,
"load_files": load_files,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

path = marker_path(root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except OSError as exc:
emit({"status": "error", "message": f"Could not write {path}: {exc}"})
return 0

emit({"status": "ok", "path": str(path)})
return 0


def cmd_read(args: argparse.Namespace) -> int:
root = workspace_lib.resolve_workspace_root()
if root is None:
# Not inside a workspace: stay silent, matching recent-projects.py.
return 0

marker = load_marker(marker_path(root))
if marker is None:
passthrough()
return 0

emit({
"systemMessage": (
f"Resuming {marker['project']} from checkpoint "
f"({humanize_age(marker['_age_seconds'])})."
),
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": build_directive(marker),
},
})
return 0


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)

write = sub.add_parser("write", help="Arm a handoff for the next /clear")
write.add_argument("--project", required=True)
write.add_argument("--next-task", required=True)
write.add_argument("--load-files", default="",
help="Comma-separated detail files, relative to the project dir")

sub.add_parser("read", help="Consume a handoff at session start (hook mode)")

return parser


def main() -> int:
args = build_parser().parse_args()
if args.command == "write":
return cmd_write(args)
if args.command == "read":
try:
return cmd_read(args)
except Exception:
# A SessionStart hook must never fail loudly. load_marker already
# swallows bad markers, so reaching here means something
# unexpected; silence beats a traceback in the user's context.
return 0
return 0


if __name__ == "__main__":
sys.exit(main())
Loading