Commit d78d542bf6ba
Changed files (8)
dots
config
claude
skills
DocCoauthoring
DocsDriftReview
Handoff
scripts
pi
agent
extensions
dots/config/claude/skills/DocsDriftReview/scripts/collect-code-files.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+find . \
+ -type f \
+ \( -iname "*.go" -o -iname "*.py" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.js" -o -iname "*.jsx" -o -iname "*.java" -o -iname "*.rs" -o -iname "*.yaml" -o -iname "*.yml" -o -iname "*.json" -o -iname "*.proto" \) |
+ sort
dots/config/claude/skills/DocsDriftReview/scripts/collect-doc-files.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+find . \
+ -type f \
+ \( -iname "*.md" -o -iname "*.mdx" -o -iname "*.rst" -o -iname "*.adoc" -o -iname "README*" \) |
+ sort
dots/config/claude/skills/DocsDriftReview/templates/report-template.md
@@ -0,0 +1,39 @@
+# Documentation Drift Review
+
+## Summary
+
+- Overall assessment:
+- Critical:
+- High:
+- Medium:
+- Low:
+
+## Top issues
+
+1.
+2.
+3.
+
+## Findings
+
+### DDR-001
+
+- Severity:
+- Confidence:
+- Docs evidence:
+- Implementation evidence:
+- Why this matters:
+- Recommended fix:
+- Proposed text:
+
+## Coverage gaps
+
+-
+
+## Reorganization suggestions
+
+-
+
+## Quick wins
+
+-
dots/config/claude/skills/DocsDriftReview/SKILL.md
@@ -0,0 +1,113 @@
+---
+name: DocsDriftReview
+description: Analyze repository documentation for implementation drift, stale examples, missing coverage, and reorganization opportunities. USE WHEN user says 'review docs', 'docs drift', 'stale docs', 'doc audit', 'check docs', or wants to compare documentation against implementation.
+---
+
+# Docs Drift Review
+
+Audit documentation against the actual implementation. Find stale content, missing coverage, and correctness issues. Produce a prioritized report with exact file references and proposed fixes.
+
+## Non-Goals
+
+- Don't invent behavior not supported by code or tests
+- Don't rewrite all docs when a targeted patch is enough
+- Don't flag style preferences as defects unless they reduce clarity
+- Don't modify files unless the user explicitly asks for edits
+
+## Workflow
+
+### Step 1: Build a Docs Map
+
+Create a compact inventory:
+- Major doc files and their purpose
+- Feature areas covered
+- Source-of-truth files for each area
+- Stale-looking sections (versioned commands, flags, env vars, API paths, copied outputs)
+
+### Step 2: Build an Implementation Map
+
+Identify:
+- Entrypoints, public interfaces
+- Commands, flags, config keys, env vars
+- API routes, request/response shapes
+- Feature gates, defaults, constraints
+- Examples, fixtures, relevant tests
+
+Prefer tests and schemas over comments when determining current behavior.
+
+### Step 3: Compare Docs Against Implementation
+
+Check for:
+- Renamed or removed commands, flags, env vars, config fields, APIs
+- Changed defaults or prerequisites
+- Outdated examples or sample outputs
+- Undocumented new behavior
+- Docs claiming support that code no longer provides
+- Missing constraints, edge cases, or failure modes
+
+For each suspected drift: find exact evidence in docs AND in code/tests. Classify as **confirmed**, **likely**, or **insufficient evidence**.
+
+### Step 4: Review Doc Quality
+
+Check for:
+- Spelling and grammar errors
+- Inconsistent naming of products, features, commands
+- Ambiguous wording, duplicate content
+- Examples without explanation or explanation without examples
+- Missing cross-links, outdated file path references
+
+### Step 5: Produce Prioritized Report
+
+## Severity Guide
+
+| Level | Criteria |
+|-------|----------|
+| **critical** | Setup-breaking, security-relevant, migration-breaking, or API-breaking doc errors |
+| **high** | Materially misleading docs that waste time or cause wrong usage |
+| **medium** | Missing context, stale examples, structural issues |
+| **low** | Spelling, grammar, naming consistency, polish |
+
+## Output Format
+
+```markdown
+### Summary
+- Overall docs health
+- Count of findings by severity
+- Top 3 issues
+
+### Findings
+For each finding:
+- ID, Severity, Confidence (confirmed/likely/uncertain)
+- Docs evidence (file:line)
+- Implementation evidence (file:line)
+- Why this matters
+- Recommended fix
+
+### Coverage Gaps
+Feature areas implemented but not documented.
+
+### Quick Wins
+Smallest high-value doc fixes.
+```
+
+## Evidence Rules
+
+- Prefer direct evidence from code, tests, schemas, generated help
+- If behavior is ambiguous, say so explicitly
+- Don't infer runtime behavior solely from type names or comments
+- If tests contradict docs, treat as drift signal and note the contradiction
+
+## Common Drift Patterns
+
+- README says one command, CLI help exposes another
+- Docs list flags not in parser definitions
+- Docs omit required env vars now enforced by code
+- Examples use old API paths or field names
+- Docs describe defaults that changed in config
+- Docs refer to moved files or directories
+- Generated CRDs changed but prose docs didn't
+
+## Related Skills
+
+- **CodeReview**: For code quality review (not docs)
+- **DocCoauthoring**: For writing new documentation
dots/config/claude/skills/Handoff/scripts/create_handoff.py
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any, Sequence
+
+SCAFFOLD_TEMPLATE = """\
+# Handoff: {name}
+
+## Goal
+
+## Current Progress
+
+## What Worked
+
+## What Didn't Work
+
+## Next Steps
+
+## Notes for Next Agent
+"""
+
+HANDOFF_DIR = Path.home() / ".local" / "share" / "ai" / "handoffs"
+
+
+def run_git(args: Sequence[str], cwd: Path) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ ["git", *args],
+ cwd=cwd,
+ text=True,
+ capture_output=True,
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Gather git context and scaffold a handoff document.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument(
+ "--repo", default=".", help="Path inside the target Git repository."
+ )
+ parser.add_argument(
+ "--branch",
+ default=None,
+ help="Branch name override (auto-detected by default).",
+ )
+ parser.add_argument(
+ "--name",
+ required=True,
+ help="Descriptive kebab-case slug for the handoff file (e.g. 'fix-auth-token-expiry').",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ dest="emit_json",
+ help="Emit JSON instead of text output.",
+ )
+ parser.add_argument(
+ "--log-count",
+ type=int,
+ default=10,
+ help="Number of recent commits to include.",
+ )
+ parser.add_argument(
+ "--new-copy-if-exists",
+ action="store_true",
+ help="Create a new uniquely named handoff file instead of reusing an existing slug.",
+ )
+ return parser.parse_args()
+
+
+def find_git_root(start: Path) -> Path | None:
+ result = run_git(["rev-parse", "--show-toplevel"], cwd=start)
+ if result.returncode != 0:
+ return None
+ return Path(result.stdout.strip())
+
+
+def detect_repo_name(root: Path) -> str:
+ result = run_git(["remote", "get-url", "origin"], cwd=root)
+ if result.returncode == 0:
+ url = result.stdout.strip()
+ # SSH: git@host:org/repo.git
+ m = re.search(r":([^/]+/[^/]+?)(?:\.git)?$", url)
+ if m:
+ return m.group(1).split("/")[-1]
+ # HTTPS: https://host/org/repo.git
+ m = re.search(r"/([^/]+?)(?:\.git)?$", url)
+ if m:
+ return m.group(1)
+ return root.name
+
+
+def detect_branch(root: Path) -> str | None:
+ result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
+ if result.returncode != 0:
+ return None
+ branch = result.stdout.strip()
+ if branch == "HEAD":
+ return None
+ return branch
+
+
+def sanitize_branch(name: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9_.-]", "-", name)
+
+
+def detect_default_branch(root: Path) -> str:
+ result = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=root)
+ if result.returncode == 0:
+ ref = result.stdout.strip()
+ # refs/remotes/origin/main -> main
+ parts = ref.split("/")
+ if parts:
+ return parts[-1]
+
+ # Fallback: check if main or master exists
+ for candidate in ("main", "master"):
+ result = run_git(["rev-parse", "--verify", candidate], cwd=root)
+ if result.returncode == 0:
+ return candidate
+
+ return "main"
+
+
+def ensure_directory(repo_name: str) -> Path:
+ directory = HANDOFF_DIR / repo_name
+ directory.mkdir(parents=True, exist_ok=True)
+ return directory
+
+
+def check_existing(handoff_path: Path) -> tuple[bool, str | None]:
+ if handoff_path.exists():
+ return False, handoff_path.read_text()
+ return True, None
+
+
+def generate_unique_path(handoff_path: Path) -> Path:
+ """Generate a unique path by appending a random suffix if file exists."""
+ if not handoff_path.exists():
+ return handoff_path
+
+ stem = handoff_path.stem
+ suffix = handoff_path.suffix
+ random_suffix = f"{random.randint(1000, 9999)}"
+ return handoff_path.parent / f"{stem}-{random_suffix}{suffix}"
+
+
+def gather_git_context(
+ root: Path, default_branch: str, log_count: int
+) -> dict[str, Any]:
+ context: dict[str, Any] = {}
+
+ # Recent commits
+ result = run_git(
+ ["log", f"{default_branch}..HEAD", "--oneline", f"-{log_count}"], cwd=root
+ )
+ commits = []
+ if result.returncode == 0:
+ for line in result.stdout.strip().splitlines():
+ if not line:
+ continue
+ parts = line.split(None, 1)
+ commits.append(
+ {
+ "hash": parts[0],
+ "message": parts[1] if len(parts) > 1 else "",
+ }
+ )
+ context["recent_commits"] = commits
+
+ # Files changed
+ result = run_git(["diff", "--name-status", f"{default_branch}...HEAD"], cwd=root)
+ files_changed = []
+ if result.returncode == 0:
+ for line in result.stdout.strip().splitlines():
+ if not line:
+ continue
+ parts = line.split(None, 1)
+ files_changed.append(
+ {
+ "status": parts[0],
+ "path": parts[1] if len(parts) > 1 else "",
+ }
+ )
+ context["files_changed"] = files_changed
+
+ # Ahead/behind
+ result = run_git(
+ ["rev-list", "--left-right", "--count", f"{default_branch}...HEAD"], cwd=root
+ )
+ if result.returncode == 0:
+ parts = result.stdout.strip().split()
+ if len(parts) == 2:
+ context["behind"] = int(parts[0])
+ context["ahead"] = int(parts[1])
+ else:
+ context["ahead"] = 0
+ context["behind"] = 0
+ else:
+ context["ahead"] = 0
+ context["behind"] = 0
+
+ # Working tree
+ result = run_git(["status", "--porcelain"], cwd=root)
+ modified = 0
+ untracked = 0
+ staged = 0
+ if result.returncode == 0:
+ for line in result.stdout.splitlines():
+ if not line or len(line) < 2:
+ continue
+ x, y = line[0], line[1]
+ if x == "?":
+ untracked += 1
+ elif x != " ":
+ staged += 1
+ if y not in (" ", "?"):
+ modified += 1
+ context["working_tree"] = {
+ "modified": modified,
+ "untracked": untracked,
+ "staged": staged,
+ }
+
+ return context
+
+
+def scaffold_template(handoff_path: Path, name: str) -> None:
+ content = SCAFFOLD_TEMPLATE.format(name=name)
+ handoff_path.write_text(content)
+
+
+def main() -> int:
+ args = parse_args()
+ start = Path(args.repo).resolve()
+
+ root = find_git_root(start)
+ if root is None:
+ print("Error: not inside a Git repository.", file=sys.stderr)
+ return 1
+
+ repo_name = detect_repo_name(root)
+
+ branch = args.branch or detect_branch(root)
+ if branch is None:
+ branch = "(detached)"
+
+ sanitized = sanitize_branch(args.name)
+ default_branch = detect_default_branch(root)
+
+ handoff_dir = ensure_directory(repo_name)
+ preferred_path = handoff_dir / f"{sanitized}.md"
+
+ is_new, existing_content = check_existing(preferred_path)
+ handoff_path = preferred_path
+
+ if is_new:
+ scaffold_template(handoff_path, args.name)
+ elif args.new_copy_if_exists:
+ handoff_path = generate_unique_path(preferred_path)
+ is_new = True
+ existing_content = None
+ scaffold_template(handoff_path, args.name)
+
+ git_context = gather_git_context(root, default_branch, args.log_count)
+
+ if args.emit_json:
+ output = {
+ "handoff_file": str(handoff_path),
+ "is_new": is_new,
+ "repo_name": repo_name,
+ "name": args.name,
+ "branch_name": branch,
+ "sanitized_name": sanitized,
+ "default_branch": default_branch,
+ "existing_content": existing_content,
+ "git_context": git_context,
+ }
+ print(json.dumps(output, indent=2))
+ else:
+ render_text(
+ handoff_path=handoff_path,
+ is_new=is_new,
+ repo_name=repo_name,
+ branch=branch,
+ default_branch=default_branch,
+ git_context=git_context,
+ existing_content=existing_content,
+ )
+
+ return 0
+
+
+def render_text(
+ *,
+ handoff_path: Path,
+ is_new: bool,
+ repo_name: str,
+ branch: str,
+ default_branch: str,
+ git_context: dict[str, Any],
+ existing_content: str | None,
+) -> None:
+ status = "created" if is_new else "exists"
+ print(f"Handoff file ({status}): {handoff_path}")
+ print(f"Repo: {repo_name}")
+ print(f"Branch: {branch}")
+ print(f"Default branch: {default_branch}")
+
+ ahead = git_context.get("ahead", 0)
+ behind = git_context.get("behind", 0)
+ print(f"Ahead: {ahead}, Behind: {behind}")
+
+ commits = git_context.get("recent_commits", [])
+ if commits:
+ print(f"\nRecent commits ({len(commits)}):")
+ for c in commits:
+ print(f" {c['hash']} {c['message']}")
+ else:
+ print("\nNo commits ahead of default branch.")
+
+ files = git_context.get("files_changed", [])
+ if files:
+ print(f"\nFiles changed ({len(files)}):")
+ for f in files:
+ print(f" {f['status']}\t{f['path']}")
+
+ wt = git_context.get("working_tree", {})
+ if any(wt.get(k, 0) > 0 for k in ("modified", "untracked", "staged")):
+ print(
+ f"\nWorking tree: {wt.get('modified', 0)} modified, "
+ f"{wt.get('untracked', 0)} untracked, {wt.get('staged', 0)} staged"
+ )
+
+ if existing_content is not None:
+ print("\n--- Existing handoff content ---")
+ print(existing_content)
+ print("--- End existing content ---")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
dots/config/claude/skills/Handoff/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: Handoff
+description: Write or update a handoff document so another agent or engineer can continue the work with fresh context. USE WHEN user says 'handoff', 'write handoff', 'create handoff', 'pass this off', or wants to capture context for cross-agent or cross-session continuity.
+---
+
+# Handoff
+
+Capture the current goal, progress, context, and next steps in a concise handoff document. Works for cross-agent handoffs (pi → claude, pi → cursor), cross-session continuity, and human-to-human knowledge transfer.
+
+## Quick Start
+
+```bash
+python3 "<path-to-skill>/scripts/create_handoff.py" --repo "." --name "<descriptive-slug>"
+# Add --json for structured output
+```
+
+## Workflow
+
+1. Inspect current git context and choose a short kebab-case slug describing the work
+2. Run the helper script to scaffold the handoff file with git context
+3. If an existing handoff exists for this slug, read it before updating
+4. Write or update the handoff using script output and current session knowledge
+5. Tell the user where the handoff file lives
+
+Use `--new-copy-if-exists` to create a fresh copy even when the slug already exists.
+
+## Handoff Contents
+
+Include these sections:
+
+### Goal
+One-sentence description of what the work aims to achieve.
+
+### Current Progress
+What has been completed, with specific file paths and commit references.
+
+### What Worked
+Approaches, tools, or patterns that proved successful.
+
+### What Didn't Work
+Dead ends, failed approaches, and why they didn't work. This saves the next agent from repeating mistakes.
+
+### Next Steps
+Concrete, ordered list of what to do next. Be specific — file paths, commands, expected outcomes.
+
+### Notes for Next Agent
+Context that doesn't fit elsewhere: environment quirks, gotchas, related issues, relevant documentation.
+
+## Storage
+
+Handoffs are stored in `~/.local/share/ai/handoffs/<repo-name>/<slug>.md`.
+
+The script auto-detects the repository name from git remote and creates the directory structure.
+
+## Integration
+
+- Works alongside `save_session_to_history` — handoff focuses on continuing work, session summary focuses on documenting what was done
+- The handoff document can be included in a new session's context to resume work
+- Compatible with any AI coding tool that can read markdown files
+
+## Related Skills
+
+- **CORE**: Session saves via `save_session_to_history` (documenting past work)
+- **WritingPlans**: For creating implementation plans (forward-looking)
dots/pi/agent/extensions/handoff.ts
@@ -1,231 +0,0 @@
-/**
- * Handoff extension - transfer context to a new focused session
- *
- * Instead of compacting (which is lossy), handoff extracts what matters
- * for your next task and creates a new session with a generated prompt.
- *
- * Usage:
- * /handoff now implement this for teams as well
- * /handoff execute phase one of the plan
- * /handoff check other places that need this fix
- *
- * The generated prompt appears as a draft in the editor for review/editing.
- */
-
-import { complete, type Message } from "@mariozechner/pi-ai";
-import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
-import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
-import { promises as fs } from "node:fs";
-import * as path from "node:path";
-
-const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
-
-1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
-2. Lists any relevant files that were discussed or modified
-3. Clearly states the next task based on the user's goal
-4. Is self-contained - the new thread should be able to proceed without the old conversation
-
-Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
-
-Example output format:
-## Context
-We've been working on X. Key decisions:
-- Decision 1
-- Decision 2
-
-Files involved:
-- path/to/file1.ts
-- path/to/file2.ts
-
-## Task
-[Clear description of what to do next based on user's goal]`;
-
-// =============================================================================
-// Helper Functions
-// =============================================================================
-
-function getYearMonth(): string {
- const now = new Date();
- const year = now.getFullYear();
- const month = String(now.getMonth() + 1).padStart(2, "0");
- return `${year}-${month}`;
-}
-
-function formatTimestamp(date: Date): string {
- return date.toISOString().replace("T", " ").slice(0, 19);
-}
-
-async function saveHandoffToStorage(data: {
- goal: string;
- originalSession: string;
- generatedPrompt: string;
- model: string;
- entryCount: number;
-}): Promise<string> {
- const home = process.env.HOME || process.env.USERPROFILE || "";
- const yearMonth = getYearMonth();
- const reviewsDir = path.join(home, ".local", "share", "ai", "sessions", yearMonth);
-
- // Ensure directory exists
- await fs.mkdir(reviewsDir, { recursive: true });
-
- const timestamp = Date.now();
- const filename = `handoff-${timestamp}.md`;
- const filepath = path.join(reviewsDir, filename);
-
- const goalSummary = data.goal.slice(0, 60);
- const now = new Date();
-
- const markdown = `# Handoff: ${goalSummary}
-
-**Created:** ${formatTimestamp(now)}
-**Original Session:** ${data.originalSession}
-**Goal:** ${data.goal}
-
-## Generated Prompt
-
-${data.generatedPrompt}
-
-## Metadata
-
-- Model: ${data.model}
-- Original session entries: ${data.entryCount}
-- Timestamp: ${timestamp}
-
-## Follow-up Actions
-
-- [ ] Create new session with this prompt
-- [ ] Review and refine context
-- [ ] Link related TODOs
-`;
-
- await fs.writeFile(filepath, markdown, "utf-8");
- return filepath;
-}
-
-export default function (pi: ExtensionAPI) {
- pi.registerCommand("handoff", {
- description: "Transfer context to a new focused session",
- handler: async (args, ctx) => {
- if (!ctx.hasUI) {
- ctx.ui.notify("handoff requires interactive mode", "error");
- return;
- }
-
- if (!ctx.model) {
- ctx.ui.notify("No model selected", "error");
- return;
- }
-
- const goal = args.trim();
- if (!goal) {
- ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
- return;
- }
-
- // Gather conversation context from current branch
- const branch = ctx.sessionManager.getBranch();
- const messages = branch
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
- .map((entry) => entry.message);
-
- if (messages.length === 0) {
- ctx.ui.notify("No conversation to hand off", "error");
- return;
- }
-
- // Convert to LLM format and serialize
- const llmMessages = convertToLlm(messages);
- const conversationText = serializeConversation(llmMessages);
- const currentSessionFile = ctx.sessionManager.getSessionFile();
-
- // Generate the handoff prompt with loader UI
- const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
- const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
- loader.onAbort = () => done(null);
-
- const doGenerate = async () => {
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
- if (!auth.ok) throw new Error(auth.error);
-
- const userMessage: Message = {
- role: "user",
- content: [
- {
- type: "text",
- text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
- },
- ],
- timestamp: Date.now(),
- };
-
- const response = await complete(
- ctx.model!,
- { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
- { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
- );
-
- if (response.stopReason === "aborted") {
- return null;
- }
-
- return response.content
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
- .map((c) => c.text)
- .join("\n");
- };
-
- doGenerate()
- .then(done)
- .catch((err) => {
- console.error("Handoff generation failed:", err);
- done(null);
- });
-
- return loader;
- });
-
- if (result === null) {
- ctx.ui.notify("Cancelled", "info");
- return;
- }
-
- // Save handoff to ai-storage
- try {
- const handoffPath = await saveHandoffToStorage({
- goal,
- originalSession: currentSessionFile,
- generatedPrompt: result,
- model: ctx.model.id,
- entryCount: messages.length,
- });
-
- ctx.ui.notify(`Handoff saved: ${path.basename(handoffPath)}`, "success");
- } catch (error: any) {
- ctx.ui.notify(`Warning: Failed to save handoff: ${error.message}`, "warning");
- }
-
- // Let user edit the generated prompt
- const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
-
- if (editedPrompt === undefined) {
- ctx.ui.notify("Cancelled", "info");
- return;
- }
-
- // Create new session with parent tracking
- const newSessionResult = await ctx.newSession({
- parentSession: currentSessionFile,
- });
-
- if (newSessionResult.cancelled) {
- ctx.ui.notify("New session cancelled", "info");
- return;
- }
-
- // Set the edited prompt in the main editor for submission
- ctx.ui.setEditorText(editedPrompt);
- ctx.ui.notify("Handoff ready. Submit when ready.", "info");
- },
- });
-}