-
Notifications
You must be signed in to change notification settings - Fork 15
workspace: last-active frontmatter + checkpoint handoff, bump to v0.2.0 #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fonta-rh
wants to merge
10
commits into
openshift-eng:main
Choose a base branch
from
fonta-rh:worktree-workspace-last-active-frontmatter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
064514f
workspace: prefer last-active frontmatter over mtime in recent-projects
fonta-rh 503fc87
workspace: update-project writes last-active frontmatter
fonta-rh 288a7fd
workspace: new-project writes last-active in frontmatter
fonta-rh 487d3b1
workspace: close-project updates last-active in frontmatter
fonta-rh d8c4395
workspace: remove unused import and dead sort_source key
fonta-rh 0e6f84d
workspace: add /workspace:checkpoint handoff across /clear
fonta-rh dd34354
workspace: bump to v0.2.0
fonta-rh 5e8e41c
Apply CodeRabbit suggestion from PR #255
fonta-rh 2347326
workspace: remove last-active implementation plan
fonta-rh 042a223
workspace: sync marketplace.json version to 0.2.0
fonta-rh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)]) | ||
|
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) | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
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 | ||
|
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})", | ||
| }) | ||
|
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, | ||
|
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") | ||
|
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()) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.