diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dcb9175f..837c1987 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" } ] } diff --git a/plugins/workspace/.claude-plugin/plugin.json b/plugins/workspace/.claude-plugin/plugin.json index 984919fa..95c67a1b 100644 --- a/plugins/workspace/.claude-plugin/plugin.json +++ b/plugins/workspace/.claude-plugin/plugin.json @@ -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", "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" diff --git a/plugins/workspace/CLAUDE.md b/plugins/workspace/CLAUDE.md index f636c79b..2dae269e 100644 --- a/plugins/workspace/CLAUDE.md +++ b/plugins/workspace/CLAUDE.md @@ -25,17 +25,18 @@ Two roots are kept strictly separate: ```text .claude-plugin/{plugin.json, marketplace.json} Plugin + marketplace manifests -skills//SKILL.md 8 skills (workspace: prefix) +skills//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 Handoff 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 @@ -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:handoff` | 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 | @@ -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. +- **Session handoff**: `/workspace:handoff` writes a single-use marker + to `/.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`, diff --git a/plugins/workspace/README.md b/plugins/workspace/README.md index 456e1cd5..48019611 100644 --- a/plugins/workspace/README.md +++ b/plugins/workspace/README.md @@ -41,6 +41,7 @@ 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:handoff` | 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 | @@ -48,7 +49,9 @@ domain, use `/workspace:create-domain`. | `/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:handoff`, that same hook instead resumes the handed-off +project on your next `/clear`. ## Concepts diff --git a/plugins/workspace/hooks/hooks.json b/plugins/workspace/hooks/hooks.json index 13f23670..14fea58c 100644 --- a/plugins/workspace/hooks/hooks.json +++ b/plugins/workspace/hooks/hooks.json @@ -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" + } + ] } ] } diff --git a/plugins/workspace/scripts/handoff.py b/plugins/workspace/scripts/handoff.py new file mode 100644 index 00000000..79152eb7 --- /dev/null +++ b/plugins/workspace/scripts/handoff.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Arm and consume the session handoff marker. + +`/workspace:handoff` 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)]) + 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) + + 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 + + +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"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})", + }) + 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, + } + + path = marker_path(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n") + 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 handoff " + f"({humanize_age(marker['_age_seconds'])})." + ), + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": build_directive(marker), + }, + }) + return 0 + + +def cmd_clear(args: argparse.Namespace) -> int: + root = workspace_lib.resolve_workspace_root() + if root is None: + emit({"status": "ok", "deleted": False}) + return 0 + + path = marker_path(root) + if not path.is_file(): + emit({"status": "ok", "deleted": False}) + return 0 + + if args.project: + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + unlink_quietly(path) + emit({"status": "ok", "deleted": True}) + return 0 + if data.get("project") != args.project: + emit({"status": "ok", "deleted": False}) + return 0 + + unlink_quietly(path) + emit({"status": "ok", "deleted": True}) + 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)") + + clear = sub.add_parser("clear", help="Remove the handoff marker") + clear.add_argument("--project", default="", + help="Only clear if marker belongs to this project") + + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.command == "write": + return cmd_write(args) + if args.command == "clear": + return cmd_clear(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()) diff --git a/plugins/workspace/scripts/recent-projects.py b/plugins/workspace/scripts/recent-projects.py index cf97f77c..72f7d025 100755 --- a/plugins/workspace/scripts/recent-projects.py +++ b/plugins/workspace/scripts/recent-projects.py @@ -62,8 +62,20 @@ def newest_mtime(directory: Path) -> float | None: TERMINAL_STATUSES = {"done", "complete", "closed"} +def _parse_last_active(value: str) -> float | None: + """Parse a YYYY-MM-DD date string into a timestamp, or None.""" + try: + parts = value.split("-") + if len(parts) != 3: + return None + y, m, d = int(parts[0]), int(parts[1]), int(parts[2]) + return datetime(y, m, d, 23, 59, 59).timestamp() + except (ValueError, OverflowError): + return None + + def collect_projects(projects_dir: Path) -> list[dict]: - """Collect non-done projects with their metadata and mtime.""" + """Collect non-done projects with their metadata, sorted by last-active date or mtime.""" entries = [] for d in sorted(projects_dir.iterdir()): if not d.is_dir() or d.name.startswith("."): @@ -73,19 +85,27 @@ def collect_projects(projects_dir: Path) -> list[dict]: if fm.get("status", "").lower() in TERMINAL_STATUSES: continue - mtime = newest_mtime(d) - if mtime is None: - continue + last_active_str = fm.get("last-active", "") + la_ts = _parse_last_active(last_active_str) if last_active_str else None + + if la_ts is not None: + sort_ts = la_ts + date_str = datetime.fromtimestamp(la_ts).strftime("%b %d") + else: + sort_ts = newest_mtime(d) + if sort_ts is None: + continue + date_str = datetime.fromtimestamp(sort_ts).strftime("%b %d %H:%M") entries.append({ "name": d.name, "type": fm.get("type", "—"), "status": fm.get("status", "—"), - "mtime": mtime, - "date_str": datetime.fromtimestamp(mtime).strftime("%b %d %H:%M"), + "sort_ts": sort_ts, + "date_str": date_str, }) - entries.sort(key=lambda e: e["mtime"], reverse=True) + entries.sort(key=lambda e: e["sort_ts"], reverse=True) return entries @@ -110,7 +130,7 @@ def main(): if not entries: sys.exit(0) - top = entries[:3] + top = entries[:5] lines = ["Recent projects:", ""] lines.append(" # NAME TYPE STATUS LAST ACTIVE") diff --git a/plugins/workspace/scripts/resume-project.py b/plugins/workspace/scripts/resume-project.py index 10d702da..29d11b80 100755 --- a/plugins/workspace/scripts/resume-project.py +++ b/plugins/workspace/scripts/resume-project.py @@ -93,6 +93,44 @@ def parse_frontmatter(path: Path) -> dict[str, Any]: return result +def stamp_last_active(claude_md: Path) -> None: + """Update or insert last-active in CLAUDE.md frontmatter to today's date.""" + try: + text = claude_md.read_text() + except OSError: + return + + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + return + + today = datetime.date.today().isoformat() + found = False + end = len(lines) + + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + end = i + break + if line.startswith("last-active:"): + lines[i] = f"last-active: {today}\n" + found = True + break + + if not found: + insert_at = end + for i, line in enumerate(lines[1:end], 1): + if line.startswith("status:"): + insert_at = i + 1 + break + lines.insert(insert_at, f"last-active: {today}\n") + + try: + claude_md.write_text("".join(lines)) + except OSError: + pass + + def normalize_worktrees(raw: Any, fallback_branch: str) -> list[dict[str, str]]: """Normalize any worktree frontmatter format to list[dict] with repo, branch, path.""" if not raw: @@ -459,6 +497,9 @@ def resolve_project(arg: str | None, root: Path) -> dict: worktree_repos = [wt["repo"] for wt in worktrees] worktree_status = resolve_worktree_status(worktrees, root) + if claude_md.is_file(): + stamp_last_active(claude_md) + return { "status": "ok", "root": str(root), diff --git a/plugins/workspace/skills/close-project/SKILL.md b/plugins/workspace/skills/close-project/SKILL.md index b197fe12..f7de2d08 100644 --- a/plugins/workspace/skills/close-project/SKILL.md +++ b/plugins/workspace/skills/close-project/SKILL.md @@ -61,7 +61,7 @@ If no notes were provided in the arguments, ask the user: Substeps 2.5a-2.5d apply if `P.worktree_status` (from Step 1's resume-project.py output) is non-empty; substep 2.5e applies if -`P.frontmatter.skills` is non-empty. If neither, skip to Step 3. +`P.frontmatter.skills` is non-empty. Substep 2.5f always runs. **2.5a. Display worktree status** @@ -165,6 +165,19 @@ symlinks surface autocomplete entries and have nothing to do with branches. The `skills:` frontmatter is cleared in Step 3b regardless of what was removable. +**2.5f. Clean up handoff marker** + +If a checkpoint was armed for this project, remove it so a subsequent +`/clear` doesn't attempt to resume a closed project: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/handoff.py" clear \ + --project "" +``` + +No output handling needed — the marker is silently removed only if it +references this project. + ## Step 3: Update Project CLAUDE.md **3a. Read the current CLAUDE.md** @@ -179,10 +192,12 @@ Using the Edit tool, update the YAML frontmatter: `status: done` 2. Add a `closed: ` field (today's date) after the `status` line. If a `closed:` field already exists, update it. -3. If worktrees were removed in Step 2.5, change the `worktrees:` +3. Update `last-active: ` to today's date (or add it after + `closed:` if it doesn't exist). +4. If worktrees were removed in Step 2.5, change the `worktrees:` list to `worktrees: []`. Leave `branch:` as-is for historical reference. -4. If the project had a `skills:` list, change it to `skills: []` +5. If the project had a `skills:` list, change it to `skills: []` (the symlinks were handled in Step 2.5e; the cleared list records that this project no longer holds any skill references). diff --git a/plugins/workspace/skills/handoff/SKILL.md b/plugins/workspace/skills/handoff/SKILL.md new file mode 100644 index 00000000..704e8345 --- /dev/null +++ b/plugins/workspace/skills/handoff/SKILL.md @@ -0,0 +1,79 @@ +--- +name: handoff +description: Save session progress to the project docs and arm a handoff for the next /clear +argument-hint: [name-or-number] +disable-model-invocation: true +--- + +# Hand Off a Session + +Record what this session accomplished, then arm a handoff so the next +session — after you press `/clear` — resumes the project automatically. + +This is the command to run at a natural breaking point. It replaces the +`/workspace:update-project` → `/clear` → `/workspace:resume-project` +sequence with `/workspace:handoff` → `/clear`. + +## Step 1: Resolve Project + +Use the project already loaded in this conversation (from +`/workspace:resume-project` or any earlier project interaction). If +`$ARGUMENTS` has a token, use that as the project name instead. + +If no project is in context and no argument was given, ask which project. + +**Do not run `resume-project.py` here.** This session is the one being wound +down; loading its JSON merely to learn a name spends the context this +command exists to save. + +## Step 2: Update the Documentation + +Invoke the `workspace:update-project` skill with the resolved project name. + +All document writing happens there, under its existing scope rules — including +its prohibition on touching `status:` frontmatter, memory files, and repo +source. Do not duplicate or second-guess that work here. + +If update-project reports it had nothing to update, continue anyway: a +handoff is still worth arming. + +## Step 3: Decide the Handoff + +From the documentation you just wrote, decide two things: + +1. **`next_task`** — the single next action, in a short phrase. This is your + judgment about what should happen next, not merely the first unchecked + checklist item. It is the thing that would otherwise be lost across the + `/clear`. +2. **`load_files`** — the detail files needed for that task, as they appear + in the Reference Files table (paths relative to the project directory). + An empty list is fine for a monolithic project. + +## Step 4: Arm the Handoff + +Run via Bash, substituting your values: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/handoff.py" write \ + --project "" \ + --next-task "" \ + --load-files "" +``` + +Omit `--load-files` when there are none. + +Parse the JSON output: + +- **`status: "ok"`** — proceed to Step 5. +- **`status: "error"`** — show the `message` and **stop**. Do not tell the + user to clear: the documentation updates from Step 2 are safely on disk, + and `/workspace:resume-project` by hand still recovers everything. + +## Step 5: Report + +Tell the user: + +> Handoff armed. Press `/clear` — the next session will resume +> `` at "" automatically. + +The handoff expires after 60 minutes and fires only once. diff --git a/plugins/workspace/skills/new-project/SKILL.md b/plugins/workspace/skills/new-project/SKILL.md index 550f7771..9c204ab3 100644 --- a/plugins/workspace/skills/new-project/SKILL.md +++ b/plugins/workspace/skills/new-project/SKILL.md @@ -361,6 +361,7 @@ Valid `status` values: `active`, `blocked`, `done` (set only by `/workspace:clos project: type: created: +last-active: status: active jira: domain: diff --git a/plugins/workspace/skills/resume-project/SKILL.md b/plugins/workspace/skills/resume-project/SKILL.md index e6eb4ca6..13852a60 100644 --- a/plugins/workspace/skills/resume-project/SKILL.md +++ b/plugins/workspace/skills/resume-project/SKILL.md @@ -143,6 +143,18 @@ Add: "Domain docs available for deeper reference (architecture, debugging)." ## Step 4: Task Selection +**If a handoff was injected into this session** — the SessionStart +context opened with "Handoff pending" and named a project, a next +task, and detail files — then the task decision has already been made: + +- Skip **4a** and **4b** entirely. Do not present a menu. +- Read the named detail files with the Read tool, joining them to `P.dir`. +- Report readiness: the loaded files, the checklist progress, and the next + task from the directive. +- Continue with **4d**, **4e**, and **4f** as normal. + +Otherwise, proceed with 4a onward. + **4a.** Build a task menu from `P.checklist.unchecked_items`. For each item, match its text and `section` against `P.reference_files` descriptions to determine which detail files are relevant. Reference-file paths are diff --git a/plugins/workspace/skills/update-project/SKILL.md b/plugins/workspace/skills/update-project/SKILL.md index 21d42b8e..f8586e84 100644 --- a/plugins/workspace/skills/update-project/SKILL.md +++ b/plugins/workspace/skills/update-project/SKILL.md @@ -44,6 +44,9 @@ Review the conversation history and identify: 4. **New detail files in Reference Files table** — files created in `projects//` not yet registered in CLAUDE.md's table. 5. **Progress entries** — milestones or outcomes to append. +6. **`last-active` timestamp** — always update `last-active: ` + in the frontmatter to today's date when any other update is applied. + If the field does not exist yet, add it after the `status:` line. If nothing to update, say so and stop. @@ -52,6 +55,10 @@ If nothing to update, say so and stop. Use the Edit tool for existing files. Use the Write tool for new detail files. Edit each file individually — do not rewrite entire files. +Always set `last-active: ` (today) in the frontmatter as +the first edit to CLAUDE.md, before applying checklist or progress +changes. This field drives the SessionStart ordering hook. + Summarize what was updated. If the session produced durable domain-level knowledge (not just project status), suggest `/workspace:update-domain` — this command never edits domain files itself. diff --git a/plugins/workspace/tests/test_handoff.py b/plugins/workspace/tests/test_handoff.py new file mode 100644 index 00000000..b670edc0 --- /dev/null +++ b/plugins/workspace/tests/test_handoff.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Tests for scripts/handoff.py. + +Standalone: python3 tests/test_handoff.py +Requires no third-party modules (nor does the script under test). + +Each test builds a throwaway workspace (dev-env.yaml + projects/) in a temp +dir and drives handoff.py as a subprocess with WORKSPACE_ROOT set, mirroring +how tests/test_skills.py isolates skills.py. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "handoff.py" +RECENT = Path(__file__).resolve().parent.parent / "scripts" / "recent-projects.py" + + +def run_handoff(ws: Path, *args: str) -> subprocess.CompletedProcess: + """Run handoff.py against workspace ws; assert exit 0; return the result.""" + result = subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, text=True, + env={**os.environ, "WORKSPACE_ROOT": str(ws)}, + ) + assert result.returncode == 0, ( + f"handoff.py exited {result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return result + + +def run_recent(ws: Path) -> subprocess.CompletedProcess: + """Run recent-projects.py directly, for passthrough comparisons.""" + return subprocess.run( + [sys.executable, str(RECENT)], + capture_output=True, text=True, + env={**os.environ, "WORKSPACE_ROOT": str(ws)}, + ) + + +def iso(offset_minutes: int = 0) -> str: + """A local-timezone ISO timestamp, offset from now.""" + stamp = datetime.now().astimezone() + timedelta(minutes=offset_minutes) + return stamp.isoformat(timespec="seconds") + + +class HandoffFixture(unittest.TestCase): + """Temp workspace with helpers to plant projects and markers.""" + + def setUp(self): + # resolve(): on macOS mkdtemp returns /var/... which is a symlink to + # /private/var/... — path-equality assertions need the real path. + self.tmp = Path(tempfile.mkdtemp(prefix="handoff-test-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.ws = self.tmp / "ws" + (self.ws / "projects").mkdir(parents=True) + (self.ws / "dev-env.yaml").write_text("repos: []\n") + + def make_project(self, name: str, status: str = "active") -> Path: + """Create projects//CLAUDE.md with frontmatter; return the dir.""" + project_dir = self.ws / "projects" / name + project_dir.mkdir(parents=True) + (project_dir / "CLAUDE.md").write_text( + f"---\nproject: {name}\ntype: bug\nstatus: {status}\n---\n\n# {name}\n" + ) + return project_dir + + def marker(self) -> Path: + return self.ws / ".claude" / "handoff.json" + + def write_marker(self, **overrides) -> Path: + """Plant a marker file directly, bypassing the write subcommand.""" + payload = { + "version": 1, + "project": "demo", + "written_at": iso(), + "next_task": "reproduce with restic disabled", + "load_files": ["investigation.md"], + } + payload.update(overrides) + path = self.marker() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + return path + + +class TestWrite(HandoffFixture): + + def test_write_creates_marker(self): + self.make_project("demo") + out = json.loads(run_handoff( + self.ws, "write", + "--project", "demo", + "--next-task", "reproduce with restic disabled", + "--load-files", "investigation.md,test-results.md", + ).stdout) + self.assertEqual(out["status"], "ok") + self.assertEqual(out["path"], str(self.marker())) + + data = json.loads(self.marker().read_text()) + self.assertEqual(data["version"], 1) + self.assertEqual(data["project"], "demo") + self.assertEqual(data["next_task"], "reproduce with restic disabled") + self.assertEqual(data["load_files"], + ["investigation.md", "test-results.md"]) + self.assertTrue(data["written_at"]) + + def test_write_unknown_project_errors_and_writes_nothing(self): + out = json.loads(run_handoff( + self.ws, "write", "--project", "nope", "--next-task", "x").stdout) + self.assertEqual(out["status"], "error") + self.assertIn("nope", out["message"]) + self.assertFalse(self.marker().exists()) + + def test_write_without_load_files_yields_empty_list(self): + self.make_project("demo") + run_handoff(self.ws, "write", "--project", "demo", "--next-task", "x") + data = json.loads(self.marker().read_text()) + self.assertEqual(data["load_files"], []) + + def test_write_strips_whitespace_in_load_files(self): + self.make_project("demo") + run_handoff(self.ws, "write", "--project", "demo", "--next-task", "x", + "--load-files", " a.md , b.md ") + data = json.loads(self.marker().read_text()) + self.assertEqual(data["load_files"], ["a.md", "b.md"]) + + def test_write_overwrites_an_existing_marker(self): + self.make_project("demo") + self.write_marker(project="demo", next_task="stale task") + run_handoff(self.ws, "write", "--project", "demo", + "--next-task", "fresh task") + data = json.loads(self.marker().read_text()) + self.assertEqual(data["next_task"], "fresh task") + + +class TestReadFresh(HandoffFixture): + + def test_fresh_marker_emits_directive(self): + self.make_project("demo") + self.write_marker(project="demo") + out = json.loads(run_handoff(self.ws, "read").stdout) + + hook_out = out["hookSpecificOutput"] + self.assertEqual(hook_out["hookEventName"], "SessionStart") + ctx = hook_out["additionalContext"] + self.assertIn("Handoff pending", ctx) + self.assertIn("demo", ctx) + self.assertIn("reproduce with restic disabled", ctx) + self.assertIn("investigation.md", ctx) + self.assertIn("workspace:resume-project", ctx) + self.assertIn("demo", out["systemMessage"]) + + def test_fresh_marker_is_consumed(self): + self.make_project("demo") + self.write_marker(project="demo") + run_handoff(self.ws, "read") + self.assertFalse(self.marker().exists()) + + def test_second_read_does_not_refire(self): + self.make_project("demo") + self.write_marker(project="demo") + run_handoff(self.ws, "read") + second = run_handoff(self.ws, "read").stdout + self.assertNotIn("Handoff pending", second) + + def test_future_timestamp_is_treated_as_fresh(self): + self.make_project("demo") + self.write_marker(project="demo", written_at=iso(5)) + out = run_handoff(self.ws, "read").stdout + self.assertIn("Handoff pending", out) + + def test_empty_load_files_renders_placeholder(self): + self.make_project("demo") + self.write_marker(project="demo", load_files=[]) + out = json.loads(run_handoff(self.ws, "read").stdout) + self.assertIn("none recorded", + out["hookSpecificOutput"]["additionalContext"]) + + def test_write_then_read_round_trip(self): + self.make_project("demo") + run_handoff(self.ws, "write", "--project", "demo", + "--next-task", "check velero CSI logs", + "--load-files", "investigation.md") + out = json.loads(run_handoff(self.ws, "read").stdout) + ctx = out["hookSpecificOutput"]["additionalContext"] + self.assertIn("check velero CSI logs", ctx) + self.assertIn("investigation.md", ctx) + self.assertFalse(self.marker().exists()) + + +class TestReadDegradation(HandoffFixture): + + def test_absent_marker_matches_recent_projects_exactly(self): + self.make_project("demo") + got = run_handoff(self.ws, "read").stdout + self.assertEqual(got, run_recent(self.ws).stdout) + self.assertIn("Recent projects", got) + + def test_stale_marker_passes_through_and_is_deleted(self): + self.make_project("demo") + self.write_marker(project="demo", written_at=iso(-61)) + got = run_handoff(self.ws, "read").stdout + self.assertNotIn("Handoff pending", got) + self.assertEqual(got, run_recent(self.ws).stdout) + self.assertFalse(self.marker().exists()) + + def test_corrupt_marker_passes_through_and_is_deleted(self): + self.make_project("demo") + path = self.marker() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json") + got = run_handoff(self.ws, "read").stdout + self.assertEqual(got, run_recent(self.ws).stdout) + self.assertFalse(path.exists()) + + def test_wrong_version_passes_through_and_is_deleted(self): + self.make_project("demo") + self.write_marker(project="demo", version=99) + got = run_handoff(self.ws, "read").stdout + self.assertNotIn("Handoff pending", got) + self.assertFalse(self.marker().exists()) + + def test_missing_next_task_passes_through(self): + self.make_project("demo") + self.write_marker(project="demo", next_task="") + got = run_handoff(self.ws, "read").stdout + self.assertNotIn("Handoff pending", got) + self.assertFalse(self.marker().exists()) + + def test_no_projects_dir_is_silent(self): + # recent-projects.py exits 0 with no output when there is nothing to + # show; the passthrough must preserve that. + shutil.rmtree(self.ws / "projects") + self.assertEqual(run_handoff(self.ws, "read").stdout, "") + + def test_no_workspace_root_is_silent(self): + env = {k: v for k, v in os.environ.items() + if k not in ("WORKSPACE_ROOT", "CLAUDE_PROJECT_DIR")} + result = subprocess.run( + [sys.executable, str(SCRIPT), "read"], + capture_output=True, text=True, cwd=str(self.tmp), env=env, + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + +class TestClear(HandoffFixture): + + def test_clear_deletes_existing_marker(self): + self.make_project("demo") + self.write_marker(project="demo") + out = json.loads(run_handoff(self.ws, "clear").stdout) + self.assertEqual(out["status"], "ok") + self.assertTrue(out["deleted"]) + self.assertFalse(self.marker().exists()) + + def test_clear_no_marker_is_noop(self): + out = json.loads(run_handoff(self.ws, "clear").stdout) + self.assertEqual(out["status"], "ok") + self.assertFalse(out["deleted"]) + + def test_clear_with_matching_project(self): + self.make_project("demo") + self.write_marker(project="demo") + out = json.loads(run_handoff( + self.ws, "clear", "--project", "demo").stdout) + self.assertTrue(out["deleted"]) + self.assertFalse(self.marker().exists()) + + def test_clear_with_nonmatching_project_preserves_marker(self): + self.make_project("demo") + self.make_project("other") + self.write_marker(project="other") + out = json.loads(run_handoff( + self.ws, "clear", "--project", "demo").stdout) + self.assertFalse(out["deleted"]) + self.assertTrue(self.marker().exists()) + + def test_clear_stale_marker_still_deleted(self): + self.make_project("demo") + self.write_marker(project="demo", written_at=iso(-120)) + out = json.loads(run_handoff(self.ws, "clear").stdout) + self.assertTrue(out["deleted"]) + self.assertFalse(self.marker().exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/workspace/tests/test_recent_projects.py b/plugins/workspace/tests/test_recent_projects.py new file mode 100644 index 00000000..509596e9 --- /dev/null +++ b/plugins/workspace/tests/test_recent_projects.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Tests for scripts/recent-projects.py. + +Standalone: python3 tests/test_recent_projects.py +No third-party dependencies (mirrors the script under test). +""" + +from __future__ import annotations + +import datetime +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def run_hook(workspace: Path, extra_args: list[str] | None = None) -> dict | None: + """Run recent-projects.py with WORKSPACE_ROOT pointing at workspace.""" + cmd = [ + sys.executable, + str(REPO_ROOT / "scripts" / "recent-projects.py"), + *(extra_args or []), + ] + env = {**os.environ, "WORKSPACE_ROOT": str(workspace)} + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + if result.returncode != 0: + return None + if not result.stdout.strip(): + return None + return json.loads(result.stdout) + + +def write_project( + projects_dir: Path, + name: str, + *, + status: str = "active", + last_active: str | None = None, + project_type: str = "bug", +) -> Path: + """Create a minimal project directory with a CLAUDE.md frontmatter.""" + d = projects_dir / name + d.mkdir(parents=True, exist_ok=True) + lines = [ + "---", + f"project: {name}", + f"type: {project_type}", + f"status: {status}", + ] + if last_active: + lines.append(f"last-active: {last_active}") + lines += ["---", "", f"# {name}", ""] + (d / "CLAUDE.md").write_text("\n".join(lines)) + return d + + +class RecentProjectsFixture(unittest.TestCase): + """Temp workspace with dev-env.yaml and projects dir.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.ws = Path(self.tmp) / "workspace" + self.ws.mkdir() + (self.ws / "dev-env.yaml").write_text("domain: test\nrepos: []\n") + self.projects = self.ws / "projects" + self.projects.mkdir() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp) + + +class TestCollectProjects(RecentProjectsFixture): + """Test project ordering logic in recent-projects.py.""" + + def test_frontmatter_date_beats_mtime(self): + """A project with last-active sorts above a newer-mtime project without it.""" + # older-project: old mtime but has last-active in the future + write_project(self.projects, "older-project", last_active="2099-01-01") + old_time = 1700000000 # 2023-11-14 + p = self.projects / "older-project" / "CLAUDE.md" + os.utime(p, (old_time, old_time)) + + # newer-project: recent mtime but no last-active + write_project(self.projects, "newer-project") + + result = run_hook(self.ws) + self.assertIsNotNone(result) + text = result["systemMessage"] + older_pos = text.index("older-project") + newer_pos = text.index("newer-project") + self.assertLess(older_pos, newer_pos, + "Project with last-active should sort before mtime-only project") + + +class TestFallbackToMtime(RecentProjectsFixture): + """When no last-active field exists, mtime ordering still works.""" + + def test_mtime_ordering_without_frontmatter(self): + """Projects without last-active still sort by newest file mtime.""" + write_project(self.projects, "first") + old_time = 1700000000 + os.utime(self.projects / "first" / "CLAUDE.md", (old_time, old_time)) + + write_project(self.projects, "second") + + result = run_hook(self.ws) + self.assertIsNotNone(result) + text = result["systemMessage"] + self.assertLess(text.index("second"), text.index("first")) + + +class TestMalformedLastActiveFallback(RecentProjectsFixture): + """A malformed last-active value falls back to mtime, not dropped.""" + + def test_malformed_date_falls_back_to_mtime(self): + """Project with invalid last-active still appears, ordered by mtime.""" + write_project(self.projects, "bad-date", last_active="not-a-date") + old_time = 1700000000 + os.utime(self.projects / "bad-date" / "CLAUDE.md", (old_time, old_time)) + + write_project(self.projects, "no-date") + + result = run_hook(self.ws) + self.assertIsNotNone(result) + text = result["systemMessage"] + self.assertIn("bad-date", text) + self.assertLess(text.index("no-date"), text.index("bad-date")) + + +class TestDoneProjectsFiltered(RecentProjectsFixture): + """Done/closed projects never appear regardless of last-active.""" + + def test_done_project_excluded(self): + write_project(self.projects, "finished", status="done", + last_active="2099-12-31") + write_project(self.projects, "active-one", last_active="2026-01-01") + + result = run_hook(self.ws) + self.assertIsNotNone(result) + self.assertNotIn("finished", result["systemMessage"]) + self.assertIn("active-one", result["systemMessage"]) + + +class TestNamesFlag(RecentProjectsFixture): + """--names output uses last-active ordering too.""" + + def test_names_ordered_by_last_active(self): + write_project(self.projects, "z-old", last_active="2026-01-01") + write_project(self.projects, "a-new", last_active="2026-08-01") + # Set z-old mtime to the future to prove mtime doesn't win + future = 2000000000 + os.utime(self.projects / "z-old" / "CLAUDE.md", (future, future)) + + cmd = [ + sys.executable, + str(REPO_ROOT / "scripts" / "recent-projects.py"), + "--names", + ] + env = {**os.environ, "WORKSPACE_ROOT": str(self.ws)} + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + names = result.stdout.strip().splitlines() + self.assertEqual(names, ["a-new", "z-old"]) + + +class TestLastActiveDateDisplay(RecentProjectsFixture): + """When last-active is present, LAST ACTIVE column shows the date, not mtime.""" + + def test_date_column_shows_frontmatter_date(self): + write_project(self.projects, "my-proj", last_active="2026-07-15") + # Force mtime to a different date + os.utime(self.projects / "my-proj" / "CLAUDE.md", (1700000000, 1700000000)) + + result = run_hook(self.ws) + self.assertIsNotNone(result) + self.assertIn("Jul 15", result["systemMessage"]) + + +class TestResumeStampsLastActive(RecentProjectsFixture): + """resume-project.py should update last-active on successful resolve.""" + + def test_resolve_stamps_last_active(self): + """Successful resolve updates last-active to today.""" + d = self.projects / "my-proj" + d.mkdir() + (d / "CLAUDE.md").write_text( + "---\nproject: my-proj\ntype: bug\nstatus: active\n" + "last-active: 2020-01-01\n---\n\n# my-proj\n" + ) + + RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "resume-project.py" + env = {**os.environ, "WORKSPACE_ROOT": str(self.ws)} + result = subprocess.run( + [sys.executable, str(RESUME_SCRIPT), "my-proj"], + capture_output=True, text=True, env=env, + ) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertEqual(data["status"], "ok") + + text = (d / "CLAUDE.md").read_text() + today = datetime.date.today().isoformat() + self.assertIn(f"last-active: {today}", text) + + def test_resolve_inserts_last_active_when_absent(self): + """If no last-active field exists, insert it after status.""" + d = self.projects / "no-la" + d.mkdir() + (d / "CLAUDE.md").write_text( + "---\nproject: no-la\ntype: bug\nstatus: active\n---\n\n# no-la\n" + ) + + RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "resume-project.py" + env = {**os.environ, "WORKSPACE_ROOT": str(self.ws)} + subprocess.run( + [sys.executable, str(RESUME_SCRIPT), "no-la"], + capture_output=True, text=True, env=env, + ) + + text = (d / "CLAUDE.md").read_text() + today = datetime.date.today().isoformat() + self.assertIn(f"last-active: {today}", text) + lines = text.splitlines() + status_idx = next(i for i, l in enumerate(lines) if l.startswith("status:")) + la_idx = next(i for i, l in enumerate(lines) if l.startswith("last-active:")) + self.assertEqual(la_idx, status_idx + 1) + + +if __name__ == "__main__": + unittest.main()